@vellumai/assistant 0.10.4-dev.202607010743.70cd9f0 → 0.10.4-dev.202607011113.fffc936

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/openapi.yaml CHANGED
@@ -18822,7 +18822,8 @@ paths:
18822
18822
  description:
18823
18823
  "Enqueues the cursor-checkpointed backfill job that indexes existing messages into the Qdrant lexical
18824
18824
  (BM25-style) collection in batches. Resumable and idempotent — re-running continues from the last checkpoint.
18825
- Pass `force: true` to reset the cursor and re-index from the beginning. Not run at daemon startup."
18825
+ Pass `force: true` to reset the cursor and re-index from the beginning. The same backfill is also auto-enqueued
18826
+ once per instance on upgrade at assistant startup."
18826
18827
  tags:
18827
18828
  - memory
18828
18829
  requestBody:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.4-dev.202607010743.70cd9f0",
3
+ "version": "0.10.4-dev.202607011113.fffc936",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -52,6 +52,11 @@ mock.module(
52
52
  }),
53
53
  );
54
54
 
55
+ import {
56
+ deleteMemoryCheckpoint,
57
+ LEXICAL_BACKFILL_COMPLETE_KEY,
58
+ setMemoryCheckpoint,
59
+ } from "../persistence/checkpoints.js";
55
60
  import { getDb } from "../persistence/db-connection.js";
56
61
  import { initializeDb } from "../persistence/db-init.js";
57
62
  import { rawRun } from "../persistence/raw-query.js";
@@ -402,12 +407,16 @@ describe("searchConversationSource with the qdrant backend", () => {
402
407
  getDb().run("DELETE FROM conversations");
403
408
  lexicalCalls = [];
404
409
  suppressIndexing = false;
410
+ // These tests exercise the populated-index (post-backfill) qdrant path, so
411
+ // mark the backfill complete. The completion gate is covered by its own test.
412
+ setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
405
413
  setOverridesForTesting({ "messages-search-backend": true });
406
414
  });
407
415
 
408
416
  afterEach(() => {
409
417
  setOverridesForTesting({});
410
418
  suppressIndexing = false;
419
+ deleteMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY);
411
420
  lexicalMockImpl = () => {
412
421
  throw new Error(
413
422
  "searchMessageIdsLexical mock not configured for this test",
@@ -442,6 +451,35 @@ describe("searchConversationSource with the qdrant backend", () => {
442
451
  ]);
443
452
  });
444
453
 
454
+ test("falls back to FTS until the backfill completion checkpoint is set, even with the qdrant flag on", async () => {
455
+ const match = await seedConversation({
456
+ title: "Pre-backfill notes",
457
+ content: "The prebackfilltoken decision is recorded here.",
458
+ });
459
+
460
+ // On an upgraded instance the historical messages are still being indexed
461
+ // by the background backfill, so the lexical collection is only partially
462
+ // populated. Until the completion checkpoint is set, the source must use
463
+ // the FTS path and never query Qdrant.
464
+ deleteMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY);
465
+ lexicalMockImpl = async () => {
466
+ throw new Error(
467
+ "searchMessageIdsLexical must not run before the backfill completes",
468
+ );
469
+ };
470
+
471
+ const result = await searchConversationSource(
472
+ "prebackfilltoken",
473
+ makeContext(),
474
+ 5,
475
+ );
476
+
477
+ expect(lexicalCalls).toHaveLength(0);
478
+ expect(result.evidence.map((item) => item.locator)).toEqual([
479
+ `${match.conversation.id}#${match.message.id}`,
480
+ ]);
481
+ });
482
+
445
483
  test("routes short/punctuation-only queries to the exact LIKE path, not Qdrant", async () => {
446
484
  const match = await seedConversation({
447
485
  title: "C++ notes",
@@ -82,6 +82,7 @@ const BASELINE: Record<string, readonly string[]> = {
82
82
  "../../../../../config/types.js",
83
83
  "../../../../../messaging/providers/slack/message-metadata.js",
84
84
  "../../../../../persistence/auto-analysis-constants.js",
85
+ "../../../../../persistence/checkpoints.js",
85
86
  "../../../../../persistence/conversation-queries.js",
86
87
  "../../../../../persistence/conversation-search-lexical.js",
87
88
  "../../../../../persistence/db-connection.js",
@@ -31,6 +31,7 @@ import {
31
31
  } from "../persistence/conversation-crud.js";
32
32
  import { getDb } from "../persistence/db-connection.js";
33
33
  import { initializeDb } from "../persistence/db-init.js";
34
+ import { startEmbeddingRuntimeManager } from "../persistence/embeddings/embedding-runtime-manager.js";
34
35
  import { startConsentRefresh } from "../platform/consent-cache.js";
35
36
  import { syncWorkspaceIdentityToPlatform } from "../platform/sync-identity.js";
36
37
  import { runMemoryStartup } from "../plugins/defaults/memory/startup.js";
@@ -879,30 +880,7 @@ export async function runDaemon(): Promise<void> {
879
880
  log.warn({ err }, "Assistant symlink installation failed — continuing");
880
881
  }
881
882
 
882
- // Download embedding runtime in background (non-blocking).
883
- // If download fails, local embeddings gracefully fall back to cloud backends.
884
- void (async () => {
885
- try {
886
- const { EmbeddingRuntimeManager } =
887
- await import("../persistence/embeddings/embedding-runtime-manager.js");
888
- const runtimeManager = new EmbeddingRuntimeManager();
889
- if (!runtimeManager.isReady()) {
890
- log.info("Downloading embedding runtime in background...");
891
- await runtimeManager.ensureInstalled();
892
- // Reset the sticky local-backend failure flag so auto mode retries
893
- // local embeddings without evicting a worker that may already be live.
894
- const { resetLocalEmbeddingFailureState } =
895
- await import("../persistence/embeddings/embedding-backend.js");
896
- resetLocalEmbeddingFailureState();
897
- log.info("Embedding runtime download complete");
898
- }
899
- } catch (err) {
900
- log.warn(
901
- { err },
902
- "Embedding runtime download failed — local embeddings will use cloud fallback",
903
- );
904
- }
905
- })();
883
+ startEmbeddingRuntimeManager();
906
884
 
907
885
  startWorkspaceHeartbeatService();
908
886
 
@@ -53,6 +53,11 @@ mock.module("../jobs-store.js", () => ({
53
53
  }));
54
54
 
55
55
  import { setOverridesForTesting } from "../../__tests__/feature-flag-test-helpers.js";
56
+ import {
57
+ deleteMemoryCheckpoint,
58
+ LEXICAL_BACKFILL_COMPLETE_KEY,
59
+ setMemoryCheckpoint,
60
+ } from "../checkpoints.js";
56
61
  import { createConversation } from "../conversation-crud.js";
57
62
  import { searchConversations } from "../conversation-queries.js";
58
63
  import { getDb } from "../db-connection.js";
@@ -61,6 +66,17 @@ import { rawRun } from "../raw-query.js";
61
66
 
62
67
  await initializeDb();
63
68
 
69
+ /**
70
+ * Mark the messages lexical-index backfill complete. The qdrant read path is
71
+ * gated on this: an upgraded instance whose backfill has not finished stays on
72
+ * fts5 so it never reads from a partially-populated `messages_lexical`. The
73
+ * qdrant-path tests below index candidates as if the backfill has drained, so
74
+ * they set this marker; the gate itself is covered by its own test.
75
+ */
76
+ function markBackfillComplete(): void {
77
+ setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
78
+ }
79
+
64
80
  function resetTables(): void {
65
81
  const db = getDb();
66
82
  db.run(`DELETE FROM messages`);
@@ -118,6 +134,8 @@ describe("searchConversations · qdrant backend", () => {
118
134
  beforeEach(() => {
119
135
  resetTables();
120
136
  memoryEnabled = true;
137
+ // These tests exercise the populated-index (post-backfill) qdrant path.
138
+ markBackfillComplete();
121
139
  searchMessageIdsLexicalMock.mockClear();
122
140
  searchMessageIdsLexicalMock.mockImplementation(async () => []);
123
141
  setOverridesForTesting({ "messages-search-backend": "qdrant" });
@@ -313,6 +331,27 @@ describe("searchConversations · qdrant backend", () => {
313
331
  ]);
314
332
  });
315
333
 
334
+ test("uses the fts5 path (not qdrant) until the backfill completion checkpoint is set", async () => {
335
+ // On an upgraded instance the historical messages are indexed by a
336
+ // background backfill. Until it drains, the lexical collection is only
337
+ // partially populated, so a qdrant read would silently miss older content.
338
+ // With the flag on but the completion checkpoint UNSET, the read must stay
339
+ // on the always-populated fts5 path and never query the lexical index.
340
+ deleteMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY);
341
+ const conv = createConversation("Notes");
342
+ insertMessage("m-1", conv.id, "the flux capacitor needs recalibration");
343
+ lexicalReturns(["m-1"]);
344
+
345
+ const results = await searchConversations("flux capacitor");
346
+
347
+ expect(searchMessageIdsLexicalMock).not.toHaveBeenCalled();
348
+ // The fts5 content match still finds the conversation and its message.
349
+ expect(results.map((r) => r.conversationId)).toEqual([conv.id]);
350
+ expect(results[0]!.matchingMessages.map((m) => m.messageId)).toEqual([
351
+ "m-1",
352
+ ]);
353
+ });
354
+
316
355
  test("non-tokenizable queries use the LIKE fallback without hitting the index", async () => {
317
356
  // Single-char / non-ASCII queries produce no tokens, so neither FTS nor the
318
357
  // sparse encoder yields terms — both backends use the LIKE content scan.
@@ -338,6 +377,9 @@ describe("searchConversations · qdrant backend", () => {
338
377
  describe("searchConversations · fts5 backend (default) ignores the lexical index", () => {
339
378
  beforeEach(() => {
340
379
  resetTables();
380
+ // Backfill completion is irrelevant on the default backend, but clear it so
381
+ // this suite does not depend on the qdrant suite's marker leaking across.
382
+ deleteMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY);
341
383
  searchMessageIdsLexicalMock.mockClear();
342
384
  lexicalReturns(["should-not-be-used"]);
343
385
  // Default backend: flag unset ⇒ fts5.
@@ -63,3 +63,48 @@ export function resetMessageCursorCheckpoint(
63
63
  messageId: "",
64
64
  });
65
65
  }
66
+
67
+ /**
68
+ * Completion sentinel for the messages lexical-index backfill. Set once the
69
+ * backfill has drained the entire `messages` table into the Qdrant lexical
70
+ * index (`messages_lexical`); cleared on a `force` re-run.
71
+ *
72
+ * Lives here — in `persistence/` — so it is the single source of truth for the
73
+ * backfill handler (plugin layer, which writes it) AND the message-search read
74
+ * sites (persistence + plugin layers, which gate the read backend on it).
75
+ * Keeping the key and its reader here lets `conversation-queries.ts` (also
76
+ * `persistence/`) read it without a persistence -> plugin import.
77
+ */
78
+ export const LEXICAL_BACKFILL_COMPLETE_KEY =
79
+ "lexical:messages:backfill_complete";
80
+
81
+ /**
82
+ * True once the messages lexical-index backfill has fully drained on this
83
+ * instance.
84
+ *
85
+ * Gates two things: the one-time startup auto-enqueue (skip when already
86
+ * complete) and — critically — the message-search read backend. An upgraded
87
+ * instance whose backfill has not finished must keep reading from SQLite FTS,
88
+ * because a `qdrant`-backed read against the still-filling `messages_lexical`
89
+ * collection returns an empty result (not a throw), so the Qdrant-error degrade
90
+ * path never fires and content search would silently return nothing. Switch
91
+ * reads to Qdrant only once this marker confirms the index is fully populated.
92
+ */
93
+ export function isLexicalBackfillComplete(): boolean {
94
+ return getMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY) === "1";
95
+ }
96
+
97
+ /**
98
+ * Clear the lexical-backfill completion sentinel so {@link
99
+ * isLexicalBackfillComplete} reads false until a run re-marks it.
100
+ *
101
+ * Called when a `force` re-index is requested — both at enqueue time (so reads
102
+ * fall back to SQLite FTS the instant the rebuild is queued, rather than serving
103
+ * from a stale/emptying `messages_lexical` collection in the window before the
104
+ * worker claims the job) and again inside the backfill handler (idempotent).
105
+ * Co-located with the key and its reader here so callers clear it without
106
+ * re-declaring the key string.
107
+ */
108
+ export function clearLexicalBackfillComplete(): void {
109
+ deleteMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY);
110
+ }
@@ -9,6 +9,7 @@ import {
9
9
  wrapUntrustedContent,
10
10
  } from "../security/untrusted-content.js";
11
11
  import { getLogger } from "../util/logger.js";
12
+ import { isLexicalBackfillComplete } from "./checkpoints.js";
12
13
  import type { ConversationRow } from "./conversation-crud.js";
13
14
  import { parseConversation } from "./conversation-crud.js";
14
15
  import { ensureDisplayOrderMigration } from "./conversation-display-order-migration.js";
@@ -553,11 +554,36 @@ function likeContentMatchMessages(
553
554
  );
554
555
  }
555
556
 
557
+ /**
558
+ * Resolve the effective message-search backend for the persistence read site.
559
+ *
560
+ * Starts from the `messages-search-backend` feature flag, then downgrades to
561
+ * `fts5` in two cases where the Qdrant `messages_lexical` collection is not a
562
+ * safe source (each would return an empty result — not a throw — so the
563
+ * Qdrant-error degrade never fires):
564
+ * 1. Memory is disabled — the per-message write path is suppressed, so the
565
+ * collection is never forward-filled. `!isMemoryEnabled()` is the
566
+ * layer-clean check available from `persistence/`.
567
+ * 2. The one-time upgrade backfill has not finished — the collection is only
568
+ * partially populated, so older content is missing until it drains.
569
+ *
570
+ * The recall read site applies the same completion gate (via the shared
571
+ * {@link isLexicalBackfillComplete}) on top of its own, stricter suppression
572
+ * check (`isMemoryIndexingSuppressed`, which also covers a disabled plugin).
573
+ */
574
+ function resolveMessageSearchBackend(): "fts5" | "qdrant" {
575
+ if (!isMemoryEnabled()) return "fts5";
576
+ const flagBackend = getMessagesSearchBackend(getConfig());
577
+ if (flagBackend === "qdrant" && !isLexicalBackfillComplete()) return "fts5";
578
+ return flagBackend;
579
+ }
580
+
556
581
  /**
557
582
  * Full-text search across message content.
558
583
  *
559
584
  * The lexical backend is selected by the `messages-search-backend` feature
560
- * flag (see {@link getMessagesSearchBackend}):
585
+ * flag (see {@link getMessagesSearchBackend}) and gated by
586
+ * {@link resolveMessageSearchBackend}:
561
587
  * - `fts5` (default): the `messages_fts` virtual table for tokenized matching.
562
588
  * - `qdrant`: the sparse `messages_lexical` Qdrant index (BM25-style).
563
589
  * Both apply the same visibility/archived SQL filtering, merge with a `LIKE`
@@ -600,9 +626,14 @@ export async function searchConversations(
600
626
  // `isMemoryIndexingSuppressed` predicate) can't be reached from here without a
601
627
  // persistence -> plugin import, and is covered at flip time by requiring the
602
628
  // backfill/population check before enabling the flag.
603
- const backend = !isMemoryEnabled()
604
- ? "fts5"
605
- : getMessagesSearchBackend(getConfig());
629
+ //
630
+ // The backfill completion gate is the second half of that population guard,
631
+ // for the common (memory-enabled) case: on an upgraded instance the historical
632
+ // messages are indexed by a background backfill, so until it has fully drained
633
+ // the `messages_lexical` collection is only partially populated and a `qdrant`
634
+ // read would silently miss older content. Stay on `fts5` until
635
+ // `isLexicalBackfillComplete()` confirms the index is whole, then switch.
636
+ const backend = resolveMessageSearchBackend();
606
637
 
607
638
  // LIKE pattern for title matching (message-content indexes don't cover titles).
608
639
  const titlePattern = likeContainsPattern(query);
@@ -571,3 +571,34 @@ export class EmbeddingRuntimeManager {
571
571
  }
572
572
  }
573
573
  }
574
+
575
+ /**
576
+ * Download the local embedding runtime in the background (non-blocking).
577
+ *
578
+ * Pre-warms the runtime (bun binary + ONNX worker scripts) so the first local
579
+ * embed doesn't pay the download cost inline. A no-op when the runtime is
580
+ * already installed. Failures are swallowed with a warning: local embeddings
581
+ * fall back to cloud backends, so a failed download must never block or crash
582
+ * daemon startup. Returns immediately; the download proceeds on its own.
583
+ */
584
+ export function startEmbeddingRuntimeManager(): void {
585
+ void (async () => {
586
+ try {
587
+ const runtimeManager = new EmbeddingRuntimeManager();
588
+ if (runtimeManager.isReady()) return;
589
+ log.info("Downloading embedding runtime in background...");
590
+ await runtimeManager.ensureInstalled();
591
+ // Reset the sticky local-backend failure flag so auto mode retries
592
+ // local embeddings without evicting a worker that may already be live.
593
+ const { resetLocalEmbeddingFailureState } =
594
+ await import("./embedding-backend.js");
595
+ resetLocalEmbeddingFailureState();
596
+ log.info("Embedding runtime download complete");
597
+ } catch (err) {
598
+ log.warn(
599
+ { err },
600
+ "Embedding runtime download failed — local embeddings will use cloud fallback",
601
+ );
602
+ }
603
+ })();
604
+ }
@@ -1,6 +1,7 @@
1
1
  import { getMessagesSearchBackend } from "../../../../../config/assistant-feature-flags.js";
2
2
  import { readSlackMetadata } from "../../../../../messaging/providers/slack/message-metadata.js";
3
3
  import { AUTO_ANALYSIS_SOURCE } from "../../../../../persistence/auto-analysis-constants.js";
4
+ import { isLexicalBackfillComplete } from "../../../../../persistence/checkpoints.js";
4
5
  import {
5
6
  buildFtsMatchQuery,
6
7
  buildRecallEvidenceExcerpt,
@@ -128,9 +129,17 @@ export async function searchConversationSource(
128
129
  // (memory disabled or the memory plugin disabled) the collection is never
129
130
  // populated, so a qdrant-backed read would silently return nothing while FTS
130
131
  // still works — force the FTS path regardless of the flag in that case.
131
- const backend = isMemoryIndexingSuppressed()
132
- ? "fts5"
133
- : getMessagesSearchBackend(context.config);
132
+ //
133
+ // Second gate: until the one-time upgrade backfill has fully drained, the
134
+ // collection holds only messages written since the write path went live —
135
+ // older history is missing. Reading from Qdrant then silently misses that
136
+ // history (an empty candidate set, not a throw), so stay on FTS until
137
+ // `isLexicalBackfillComplete()` confirms the index is whole. Shared with the
138
+ // persistence read site via the same checkpoint helper.
139
+ const backend =
140
+ isMemoryIndexingSuppressed() || !isLexicalBackfillComplete()
141
+ ? "fts5"
142
+ : getMessagesSearchBackend(context.config);
134
143
 
135
144
  // Tokenize once. Short/punctuation-only queries (`C++`, CJK) produce no
136
145
  // usable ≥2-char match shape, and both backends must route those straight to
@@ -69,12 +69,24 @@ mock.module(
69
69
  // `generateSparseEmbedding` is a pure local TF-IDF encoder (no provider call),
70
70
  // so it runs unmocked — mocking `embedding-backend.js` wholesale would starve
71
71
  // its other named exports that the db-init import graph pulls in.
72
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
73
+ import { join } from "node:path";
74
+
72
75
  import { resetDbForTesting } from "../../../../../__tests__/db-test-helpers.js";
73
76
  import { DEFAULT_CONFIG } from "../../../../../config/defaults.js";
77
+ import {
78
+ invalidateConfigCache,
79
+ loadRawConfig,
80
+ saveRawConfig,
81
+ } from "../../../../../config/loader.js";
74
82
  import type { AssistantConfig } from "../../../../../config/types.js";
75
83
  import {
84
+ deleteMemoryCheckpoint,
85
+ isLexicalBackfillComplete,
86
+ LEXICAL_BACKFILL_COMPLETE_KEY,
76
87
  readMessageCursorCheckpoint,
77
88
  resetMessageCursorCheckpoint,
89
+ setMemoryCheckpoint,
78
90
  writeMessageCursorCheckpoint,
79
91
  } from "../../../../../persistence/checkpoints.js";
80
92
  import {
@@ -89,7 +101,17 @@ import {
89
101
  memoryJobs,
90
102
  messages,
91
103
  } from "../../../../../persistence/schema/index.js";
92
- import { backfillLexicalIndexJob } from "../backfill-lexical-index.js";
104
+ import type {
105
+ RouteDefinition,
106
+ RouteHandlerArgs,
107
+ } from "../../../../../runtime/routes/types.js";
108
+ import { getWorkspacePluginsDir } from "../../../../../util/platform.js";
109
+ import memoryPkg from "../../package.json" with { type: "json" };
110
+ import { ROUTES as MESSAGES_LEXICAL_ROUTES } from "../../routes/messages-lexical-routes.js";
111
+ import {
112
+ backfillLexicalIndexJob,
113
+ maybeEnqueueLexicalBackfillOnUpgrade,
114
+ } from "../backfill-lexical-index.js";
93
115
 
94
116
  const TEST_CONFIG: AssistantConfig = DEFAULT_CONFIG;
95
117
 
@@ -147,6 +169,55 @@ function pendingBackfillJobCount(): number {
147
169
  return rows.length;
148
170
  }
149
171
 
172
+ /**
173
+ * Invoke the `messages_lexical_backfill` route handler directly through its
174
+ * shared `ROUTES` entry (transport-agnostic), the same way the HTTP/IPC adapters
175
+ * call it. Returns the handler result (`{ jobId }`).
176
+ */
177
+ async function callBackfillRoute(
178
+ body: Record<string, unknown>,
179
+ ): Promise<{ jobId: string }> {
180
+ const route = MESSAGES_LEXICAL_ROUTES.find(
181
+ (r: RouteDefinition) => r.operationId === "messages_lexical_backfill",
182
+ );
183
+ if (!route) throw new Error("messages_lexical_backfill route not found");
184
+ const args: RouteHandlerArgs = { body };
185
+ return (await route.handler(args)) as { jobId: string };
186
+ }
187
+
188
+ /**
189
+ * Toggle `memory.enabled` in the real workspace config so the startup hook's
190
+ * `isMemoryEnabled()` (which reads `getConfig()`) sees the change. Driving the
191
+ * real config instead of mocking `jobs-store` avoids a process-global
192
+ * `mock.module` leaking `isMemoryEnabled` into sibling test files.
193
+ */
194
+ function setMemoryEnabled(enabled: boolean): void {
195
+ const raw = loadRawConfig();
196
+ const memory =
197
+ raw.memory && typeof raw.memory === "object"
198
+ ? (raw.memory as Record<string, unknown>)
199
+ : {};
200
+ saveRawConfig({ ...raw, memory: { ...memory, enabled } });
201
+ invalidateConfigCache();
202
+ }
203
+
204
+ /**
205
+ * Toggle the memory plugin's `.disabled` sentinel in the real workspace plugins
206
+ * dir so `isMemoryIndexingSuppressed()` (via `isPluginDisabled(memoryPkg.name)`)
207
+ * sees the change. Driving the real sentinel — not a process-global mock —
208
+ * exercises the same suppression path the write/recall gates use and avoids
209
+ * leaking a mock into sibling test files.
210
+ */
211
+ function setMemoryPluginDisabled(disabled: boolean): void {
212
+ const dir = join(getWorkspacePluginsDir(), memoryPkg.name);
213
+ if (disabled) {
214
+ mkdirSync(dir, { recursive: true });
215
+ writeFileSync(join(dir, ".disabled"), "");
216
+ } else {
217
+ rmSync(dir, { recursive: true, force: true });
218
+ }
219
+ }
220
+
150
221
  describe("backfillLexicalIndexJob", () => {
151
222
  // initializeDb runs the full migration chain; under parallel CI load it can
152
223
  // exceed bun's default 5s hook timeout, so allow more.
@@ -157,6 +228,8 @@ describe("backfillLexicalIndexJob", () => {
157
228
  beforeEach(async () => {
158
229
  upsertBatchCalls.length = 0;
159
230
  resetLexicalSingleton(true);
231
+ setMemoryEnabled(true);
232
+ setMemoryPluginDisabled(false);
160
233
  resetDbForTesting();
161
234
  await initializeDb();
162
235
  // The template-restore path leaves stale WAL sidecars, so rows can bleed
@@ -166,6 +239,7 @@ describe("backfillLexicalIndexJob", () => {
166
239
  getDb().delete(conversations).run();
167
240
  getMemoryDb()!.delete(memoryJobs).run();
168
241
  resetMessageCursorCheckpoint(CHECKPOINT_KEY, CHECKPOINT_ID_KEY);
242
+ deleteMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY);
169
243
  }, 30_000);
170
244
 
171
245
  test("indexes a batch of messages and advances the checkpoint", async () => {
@@ -202,8 +276,9 @@ describe("backfillLexicalIndexJob", () => {
202
276
  );
203
277
  expect(cursor).toEqual({ createdAt: 2_000, messageId: "msg-b" });
204
278
 
205
- // A short batch does not re-enqueue.
279
+ // A short batch does not re-enqueue, and marks the backfill complete.
206
280
  expect(pendingBackfillJobCount()).toBe(0);
281
+ expect(isLexicalBackfillComplete()).toBe(true);
207
282
  });
208
283
 
209
284
  test("resumes from the persisted cursor, skipping already-indexed rows", async () => {
@@ -251,12 +326,18 @@ describe("backfillLexicalIndexJob", () => {
251
326
  expect(upsertBatchCalls[0]).toHaveLength(200);
252
327
  // Exactly one follow-up job enqueued to process the remaining rows.
253
328
  expect(pendingBackfillJobCount()).toBe(1);
329
+ // A full batch means more rows may remain, so the backfill is NOT yet
330
+ // complete — the completion marker must stay unset until a short batch.
331
+ expect(isLexicalBackfillComplete()).toBe(false);
254
332
  });
255
333
 
256
- test("does nothing (no upsert, no re-enqueue) when there are no messages", async () => {
334
+ test("does nothing (no upsert, no re-enqueue) when there are no messages, and marks complete", async () => {
257
335
  await backfillLexicalIndexJob(makeJob({}), TEST_CONFIG);
258
336
  expect(upsertBatchCalls).toHaveLength(0);
259
337
  expect(pendingBackfillJobCount()).toBe(0);
338
+ // An empty batch is a drained backfill (an already-empty or fully-indexed
339
+ // table) — the completion marker is written.
340
+ expect(isLexicalBackfillComplete()).toBe(true);
260
341
  });
261
342
 
262
343
  test("force resets the cursor and re-indexes from the beginning", async () => {
@@ -268,11 +349,13 @@ describe("backfillLexicalIndexJob", () => {
268
349
  createdAt: 5_000,
269
350
  });
270
351
 
271
- // A stale cursor past every row would normally skip everything.
352
+ // A stale cursor past every row would normally skip everything, and a prior
353
+ // run had already marked the backfill complete.
272
354
  writeMessageCursorCheckpoint(CHECKPOINT_KEY, CHECKPOINT_ID_KEY, {
273
355
  createdAt: 9_999,
274
356
  messageId: "msg-zzz",
275
357
  });
358
+ setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
276
359
 
277
360
  await backfillLexicalIndexJob(makeJob({ force: true }), TEST_CONFIG);
278
361
 
@@ -286,6 +369,32 @@ describe("backfillLexicalIndexJob", () => {
286
369
  CHECKPOINT_ID_KEY,
287
370
  );
288
371
  expect(cursor).toEqual({ createdAt: 5_000, messageId: "msg-x" });
372
+
373
+ // force cleared the completion marker; the short re-index batch then drained
374
+ // and re-marked it complete.
375
+ expect(isLexicalBackfillComplete()).toBe(true);
376
+ });
377
+
378
+ test("force clears the completion marker even when the re-run does not drain in one batch", async () => {
379
+ insertConversation("conv-force-full");
380
+ // A full batch on the forced run: the marker must be CLEARED (re-armed) and
381
+ // stay unset because more rows may remain past this batch.
382
+ for (let i = 0; i < 200; i++) {
383
+ insertMessage({
384
+ id: `fm-${String(i).padStart(4, "0")}`,
385
+ conversationId: "conv-force-full",
386
+ content: `forced message ${i}`,
387
+ createdAt: 1_000 + i,
388
+ });
389
+ }
390
+ setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
391
+
392
+ await backfillLexicalIndexJob(makeJob({ force: true }), TEST_CONFIG);
393
+
394
+ expect(upsertBatchCalls[0]).toHaveLength(200);
395
+ expect(pendingBackfillJobCount()).toBe(1);
396
+ // Marker was cleared by force and not re-set (batch was full → more remain).
397
+ expect(isLexicalBackfillComplete()).toBe(false);
289
398
  });
290
399
 
291
400
  test("lazily initializes the index when not yet initialized (worker process)", async () => {
@@ -307,4 +416,108 @@ describe("backfillLexicalIndexJob", () => {
307
416
  expect(upsertBatchCalls).toHaveLength(1);
308
417
  expect(upsertBatchCalls[0].map((p) => p.messageId)).toEqual(["msg-w"]);
309
418
  });
419
+
420
+ // The one-time startup auto-enqueue. Inherits the outer beforeEach, which
421
+ // resets the DB, clears the memory_jobs table and completion marker, and sets
422
+ // memoryEnabled = true — so each test starts from a fresh, memory-enabled,
423
+ // never-backfilled instance.
424
+ describe("maybeEnqueueLexicalBackfillOnUpgrade", () => {
425
+ test("enqueues exactly one backfill job when memory is enabled and the checkpoint is unset", () => {
426
+ maybeEnqueueLexicalBackfillOnUpgrade();
427
+
428
+ const rows = getMemoryDb()!
429
+ .select({ type: memoryJobs.type })
430
+ .from(memoryJobs)
431
+ .all();
432
+ expect(rows).toHaveLength(1);
433
+ expect(rows[0].type).toBe("backfill_lexical_index");
434
+ });
435
+
436
+ test("does NOT enqueue when the completion checkpoint is already set", () => {
437
+ setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
438
+
439
+ maybeEnqueueLexicalBackfillOnUpgrade();
440
+
441
+ expect(pendingBackfillJobCount()).toBe(0);
442
+ });
443
+
444
+ test("does NOT enqueue when memory is disabled", () => {
445
+ setMemoryEnabled(false);
446
+
447
+ maybeEnqueueLexicalBackfillOnUpgrade();
448
+
449
+ expect(pendingBackfillJobCount()).toBe(0);
450
+ });
451
+
452
+ // Indexing is suppressed by the memory plugin's `.disabled` sentinel even
453
+ // while `memory.enabled` is true. The hook gates on the same
454
+ // `isMemoryIndexingSuppressed` signal as the write/recall paths, so it must
455
+ // skip: enqueuing (and later setting the completion marker) would leave a
456
+ // stale index that a lexical-backed read could serve while per-message writes
457
+ // stay suppressed.
458
+ test("does NOT enqueue when the memory plugin is disabled but memory.enabled is true", () => {
459
+ setMemoryEnabled(true);
460
+ setMemoryPluginDisabled(true);
461
+
462
+ maybeEnqueueLexicalBackfillOnUpgrade();
463
+
464
+ expect(pendingBackfillJobCount()).toBe(0);
465
+ // The completion marker must stay unset so reads stay on FTS.
466
+ expect(isLexicalBackfillComplete()).toBe(false);
467
+ });
468
+
469
+ test("does NOT enqueue a duplicate when a backfill job is already pending", () => {
470
+ // First call enqueues the one-time job.
471
+ maybeEnqueueLexicalBackfillOnUpgrade();
472
+ expect(pendingBackfillJobCount()).toBe(1);
473
+
474
+ // A second call (e.g. a restart mid-backfill) must not pile on a duplicate
475
+ // while the prior job is still pending.
476
+ maybeEnqueueLexicalBackfillOnUpgrade();
477
+ expect(pendingBackfillJobCount()).toBe(1);
478
+ });
479
+ });
480
+
481
+ // The manual backfill route (`handleBackfillLexicalIndex`). Inherits the outer
482
+ // beforeEach, which resets the DB, clears the memory_jobs table and completion
483
+ // marker, and enables memory — so each test starts from a fresh instance.
484
+ describe("handleBackfillLexicalIndex route", () => {
485
+ test("force: true clears the completion marker synchronously at enqueue, before the job runs", async () => {
486
+ // Pretend a prior backfill already fully drained on this instance, so
487
+ // reads would be serving from the lexical index.
488
+ setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
489
+ expect(isLexicalBackfillComplete()).toBe(true);
490
+
491
+ const { jobId } = await callBackfillRoute({ force: true });
492
+ expect(jobId).toBeTruthy();
493
+
494
+ // The marker is cleared the instant the forced rebuild is enqueued —
495
+ // before the worker claims the job — so reads immediately fall back to
496
+ // FTS instead of serving from the stale/emptying lexical index.
497
+ expect(isLexicalBackfillComplete()).toBe(false);
498
+ // The job was enqueued with the force payload for the worker to process.
499
+ expect(pendingBackfillJobCount()).toBe(1);
500
+ });
501
+
502
+ test("non-force enqueue does NOT clear the completion marker", async () => {
503
+ // A completed instance stays on the lexical read path for a resume-style
504
+ // (non-force) enqueue — only a forced rebuild re-arms the FTS fallback.
505
+ setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
506
+ expect(isLexicalBackfillComplete()).toBe(true);
507
+
508
+ const { jobId } = await callBackfillRoute({});
509
+ expect(jobId).toBeTruthy();
510
+
511
+ expect(isLexicalBackfillComplete()).toBe(true);
512
+ expect(pendingBackfillJobCount()).toBe(1);
513
+ });
514
+
515
+ test("force: false is treated as non-force and leaves the marker set", async () => {
516
+ setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
517
+
518
+ await callBackfillRoute({ force: false });
519
+
520
+ expect(isLexicalBackfillComplete()).toBe(true);
521
+ });
522
+ });
310
523
  });
@@ -2,8 +2,12 @@ import { and, asc, eq, gt, or } from "drizzle-orm";
2
2
 
3
3
  import type { AssistantConfig } from "../../../../config/types.js";
4
4
  import {
5
+ clearLexicalBackfillComplete,
6
+ isLexicalBackfillComplete,
7
+ LEXICAL_BACKFILL_COMPLETE_KEY,
5
8
  readMessageCursorCheckpoint,
6
9
  resetMessageCursorCheckpoint,
10
+ setMemoryCheckpoint,
7
11
  writeMessageCursorCheckpoint,
8
12
  } from "../../../../persistence/checkpoints.js";
9
13
  import { getDb } from "../../../../persistence/db-connection.js";
@@ -11,10 +15,17 @@ import { generateSparseEmbedding } from "../../../../persistence/embeddings/embe
11
15
  import { withQdrantBreaker } from "../../../../persistence/embeddings/qdrant-circuit-breaker.js";
12
16
  import {
13
17
  enqueueMemoryJob,
18
+ hasActiveJobOfType,
14
19
  type MemoryJob,
15
20
  } from "../../../../persistence/jobs-store.js";
16
21
  import { messages } from "../../../../persistence/schema/index.js";
17
- import { resolveLexicalIndex } from "./index-message-lexical.js";
22
+ import { getLogger } from "../../../../util/logger.js";
23
+ import {
24
+ isMemoryIndexingSuppressed,
25
+ resolveLexicalIndex,
26
+ } from "./index-message-lexical.js";
27
+
28
+ const log = getLogger("lexical-backfill");
18
29
 
19
30
  /**
20
31
  * Cursor checkpoint keys for the lexical-index backfill. Kept distinct from the
@@ -25,6 +36,66 @@ import { resolveLexicalIndex } from "./index-message-lexical.js";
25
36
  const LEXICAL_BACKFILL_CHECKPOINT_KEY = "lexical:messages:last_created_at";
26
37
  const LEXICAL_BACKFILL_CHECKPOINT_ID_KEY = "lexical:messages:last_id";
27
38
 
39
+ /**
40
+ * One-time, self-healing auto-enqueue of the messages lexical-index backfill,
41
+ * invoked once from `runMemoryStartup` (startup.ts) on daemon boot. Ensures each
42
+ * instance populates its Qdrant lexical index (`messages_lexical`) exactly once
43
+ * on upgrade — in the background — so a later read-path flip to the lexical
44
+ * backend does not open onto an empty index.
45
+ *
46
+ * Deliberate, narrow exception to the "not run at daemon startup" note carried by
47
+ * the manual backfill route (`messages-lexical-routes.ts`, added in the FTS→Qdrant
48
+ * PR 5): this call only *enqueues* a memory job — it does no indexing, no LLM
49
+ * work, and no other heavy lifting on the boot thread. The backfill itself runs
50
+ * off the event loop via the existing memory job worker, in small
51
+ * cursor-checkpointed batches. It is a one-time, checkpoint-guarded, no-LLM data
52
+ * backfill (migration-like), so it is consistent with the daemon-startup
53
+ * philosophy of never blocking or doing expensive work at boot.
54
+ *
55
+ * Guards (all must pass to enqueue):
56
+ * - Memory indexing must not be suppressed — memory enabled AND the memory
57
+ * plugin not disabled — using the same {@link isMemoryIndexingSuppressed}
58
+ * signal as the write/recall paths. When `memory.enabled === false` the memory
59
+ * job worker drains nothing (`runMemoryJobsOnce` returns early), so an enqueued
60
+ * backfill would sit pending forever and only accumulate dead rows. When the
61
+ * memory plugin is disabled via its `.disabled` sentinel, per-message index
62
+ * writes are suppressed, so completing a backfill would leave a stale index
63
+ * that a lexical-backed read could serve; gating on the same signal keeps the
64
+ * completion marker unset while writes are suppressed, so every read path stays
65
+ * on FTS in that state (the marker unifies them).
66
+ * - The completion sentinel must be unset. Once the backfill has fully drained
67
+ * on this instance there is nothing to do; the marker makes this idempotent
68
+ * across restarts.
69
+ * - No backfill job may already be pending or running. Without this, every
70
+ * restart during a long backfill would pile up a duplicate job. (The batches
71
+ * are idempotent, but redundant jobs waste worker cycles.)
72
+ *
73
+ * An instance that was already manually backfilled via the route but predates
74
+ * the completion sentinel re-enqueues once here and re-runs the idempotent
75
+ * backfill (no duplicate points; ~minutes) — an acceptable one-time cost.
76
+ *
77
+ * Best-effort and lightweight: any failure (e.g. the memory database is briefly
78
+ * unavailable at boot) is swallowed and logged so a memory hiccup never blocks
79
+ * daemon startup, matching the surrounding startup steps.
80
+ */
81
+ export function maybeEnqueueLexicalBackfillOnUpgrade(): void {
82
+ try {
83
+ if (isMemoryIndexingSuppressed()) return;
84
+ if (isLexicalBackfillComplete()) return;
85
+ if (hasActiveJobOfType("backfill_lexical_index")) return;
86
+ const jobId = enqueueMemoryJob("backfill_lexical_index", {});
87
+ log.info(
88
+ { jobId },
89
+ "Enqueued one-time messages lexical-index backfill on startup",
90
+ );
91
+ } catch (err) {
92
+ log.warn(
93
+ { err },
94
+ "Failed to enqueue startup lexical-index backfill (non-fatal)",
95
+ );
96
+ }
97
+ }
98
+
28
99
  /** Messages indexed per job invocation before the job re-enqueues itself. */
29
100
  const BATCH_SIZE = 200;
30
101
 
@@ -51,6 +122,12 @@ export async function backfillLexicalIndexJob(
51
122
  LEXICAL_BACKFILL_CHECKPOINT_KEY,
52
123
  LEXICAL_BACKFILL_CHECKPOINT_ID_KEY,
53
124
  );
125
+ // Clearing the completion sentinel lets a forced re-run drain and re-mark
126
+ // as complete, and re-arms the one-time startup auto-enqueue for this run.
127
+ // The route also clears this at enqueue time (so reads fall back to FTS
128
+ // before the worker claims the job); clearing again here is idempotent and
129
+ // keeps the forced-run semantics self-contained in the handler.
130
+ clearLexicalBackfillComplete();
54
131
  }
55
132
 
56
133
  const cursor = readMessageCursorCheckpoint(
@@ -104,5 +181,12 @@ export async function backfillLexicalIndexJob(
104
181
  // happened this invocation, and re-resetting would loop over the same head.
105
182
  if (batch.length === BATCH_SIZE) {
106
183
  enqueueMemoryJob("backfill_lexical_index", {});
184
+ } else {
185
+ // A short batch (including an empty one) means the cursor has reached the
186
+ // end of the `messages` table — the backfill has fully drained. Record the
187
+ // completion sentinel so the one-time startup auto-enqueue never schedules
188
+ // it again on this instance. Idempotent: re-running after completion draws
189
+ // an empty batch and simply re-sets the same marker.
190
+ setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
107
191
  }
108
192
  }
@@ -5,12 +5,15 @@
5
5
  * Deliberately NOT gated on `memory.v2.enabled`: the lexical index is a
6
6
  * BM25-style full-text replacement for message search that is independent of
7
7
  * the v2 concept-page machinery. The backfill runs off the event loop as a
8
- * cursor-checkpointed memory job and is never triggered at daemon startup — an
9
- * operator (or the client) enqueues it on demand.
8
+ * cursor-checkpointed memory job. This route enqueues it on demand (operator or
9
+ * client); the one-time, checkpoint-guarded startup auto-enqueue in
10
+ * `runMemoryStartup` (see `maybeEnqueueLexicalBackfillOnUpgrade`) enqueues the
11
+ * same job once per instance on upgrade.
10
12
  */
11
13
 
12
14
  import { z } from "zod";
13
15
 
16
+ import { clearLexicalBackfillComplete } from "../../../../persistence/checkpoints.js";
14
17
  import {
15
18
  enqueueMemoryJob,
16
19
  type MemoryJobType,
@@ -42,6 +45,14 @@ async function handleBackfillLexicalIndex({
42
45
  body = {},
43
46
  }: RouteHandlerArgs): Promise<MessagesLexicalBackfillResult> {
44
47
  const { force } = MessagesLexicalBackfillParams.parse(body);
48
+ if (force === true) {
49
+ // Clear the completion sentinel at enqueue time so `isLexicalBackfillComplete()`
50
+ // flips false immediately — the read paths fall back to SQLite FTS in the window
51
+ // between enqueue and the worker claiming the job, instead of serving from the
52
+ // stale/emptying `messages_lexical` collection the forced rebuild is about to
53
+ // reset. The handler clears it again when it runs (idempotent).
54
+ clearLexicalBackfillComplete();
55
+ }
45
56
  const payload: Record<string, unknown> =
46
57
  force === true ? { force: true } : {};
47
58
  const jobId = enqueueMemoryJob(BACKFILL_LEXICAL_INDEX_JOB, payload);
@@ -60,7 +71,7 @@ export const ROUTES: RouteDefinition[] = [
60
71
  handler: handleBackfillLexicalIndex,
61
72
  summary: "Enqueue a resumable backfill of messages into the lexical index",
62
73
  description:
63
- "Enqueues the cursor-checkpointed backfill job that indexes existing messages into the Qdrant lexical (BM25-style) collection in batches. Resumable and idempotent — re-running continues from the last checkpoint. Pass `force: true` to reset the cursor and re-index from the beginning. Not run at daemon startup.",
74
+ "Enqueues the cursor-checkpointed backfill job that indexes existing messages into the Qdrant lexical (BM25-style) collection in batches. Resumable and idempotent — re-running continues from the last checkpoint. Pass `force: true` to reset the cursor and re-index from the beginning. The same backfill is also auto-enqueued once per instance on upgrade at assistant startup.",
64
75
  tags: ["memory"],
65
76
  requestBody: MessagesLexicalBackfillParams,
66
77
  },
@@ -30,6 +30,7 @@ import { startMemoryJobsWorker } from "../../../persistence/jobs-worker.js";
30
30
  import { getLogger } from "../../../util/logger.js";
31
31
  import { getWorkspaceDir } from "../../../util/platform.js";
32
32
  import { resolveQdrantUrl } from "./embeddings.js";
33
+ import { maybeEnqueueLexicalBackfillOnUpgrade } from "./job-handlers/backfill-lexical-index.js";
33
34
  import { sweepConceptPageFrontmatter } from "./v2/frontmatter-sweep.js";
34
35
  import {
35
36
  maybeRebuildMemoryV2Concepts,
@@ -206,6 +207,14 @@ export async function runMemoryStartup(config: AssistantConfig): Promise<void> {
206
207
  registerMemoryJobHandlers();
207
208
  startMemoryJobsWorker();
208
209
 
210
+ // One-time, self-healing backfill of existing messages into the Qdrant
211
+ // lexical index (`messages_lexical`) on upgrade, so a later read-path flip to
212
+ // the lexical backend never opens onto an empty index. Enqueue-only and
213
+ // checkpoint-guarded — the indexing runs off the event loop via the memory
214
+ // worker started just above; see the function's docstring for the guards and
215
+ // the deliberate exception it makes to the "no work at daemon startup" rule.
216
+ maybeEnqueueLexicalBackfillOnUpgrade();
217
+
209
218
  // Seed capability graph nodes (new memory graph system)
210
219
  try {
211
220
  const { seedCliGraphNodes } = await import("./graph/capability-seed.js");
@@ -7,15 +7,15 @@
7
7
  * - `hasManagedProxyPrereqs()` — true when the daemon has the credentials
8
8
  * it needs to act as a managed-proxy client, i.e. this is a managed
9
9
  * deployment.
10
- * - `getDaemonRuntimeMode()` — `"docker"` vs `"bare-metal"`, identifying
11
- * where the daemon process is running.
10
+ * - `getIsContainerized()` — true when the daemon runs inside a container
11
+ * (`IS_CONTAINERIZED`), identifying where the daemon process is running.
12
12
  *
13
13
  * Folding both into a single helper keeps callers from repeating the
14
14
  * combination logic.
15
15
  */
16
16
 
17
+ import { getIsContainerized } from "../../config/env-registry.js";
17
18
  import { hasManagedProxyPrereqs } from "../../providers/platform-proxy/context.js";
18
- import { getDaemonRuntimeMode } from "../runtime-mode.js";
19
19
 
20
20
  export type VBundleOriginMode =
21
21
  | "managed"
@@ -26,14 +26,14 @@ export type VBundleOriginMode =
26
26
  * Returns the origin mode for the current daemon.
27
27
  *
28
28
  * Managed-proxy prereqs win first (a managed deployment is always
29
- * "managed" regardless of where the daemon process runs); otherwise docker
30
- * → "self-hosted-remote", bare-metal → "self-hosted-local".
29
+ * "managed" regardless of where the daemon process runs); otherwise
30
+ * containerized → "self-hosted-remote", bare-metal → "self-hosted-local".
31
31
  */
32
32
  export async function getOriginMode(): Promise<VBundleOriginMode> {
33
33
  if (await hasManagedProxyPrereqs()) {
34
34
  return "managed";
35
35
  }
36
- if (getDaemonRuntimeMode() === "docker") {
36
+ if (getIsContainerized()) {
37
37
  return "self-hosted-remote";
38
38
  }
39
39
  return "self-hosted-local";
@@ -1,62 +0,0 @@
1
- /**
2
- * Tests for `getDaemonRuntimeMode()` — ensures the helper reflects
3
- * `IS_CONTAINERIZED` environment state via the existing truthy-check
4
- * semantics shared with `getIsContainerized()`.
5
- */
6
-
7
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
-
9
- import { getDaemonRuntimeMode } from "../runtime-mode.js";
10
-
11
- describe("getDaemonRuntimeMode", () => {
12
- let savedIsContainerized: string | undefined;
13
-
14
- beforeEach(() => {
15
- savedIsContainerized = process.env.IS_CONTAINERIZED;
16
- });
17
-
18
- afterEach(() => {
19
- if (savedIsContainerized === undefined) {
20
- delete process.env.IS_CONTAINERIZED;
21
- } else {
22
- process.env.IS_CONTAINERIZED = savedIsContainerized;
23
- }
24
- });
25
-
26
- test("returns 'docker' when IS_CONTAINERIZED=true", () => {
27
- process.env.IS_CONTAINERIZED = "true";
28
- expect(getDaemonRuntimeMode()).toBe("docker");
29
- });
30
-
31
- test("returns 'docker' when IS_CONTAINERIZED=1", () => {
32
- // Matches the existing truthy-check in getIsContainerized(), which
33
- // treats "1" as equivalent to "true" for boolean env flags.
34
- process.env.IS_CONTAINERIZED = "1";
35
- expect(getDaemonRuntimeMode()).toBe("docker");
36
- });
37
-
38
- test("returns 'bare-metal' when IS_CONTAINERIZED is unset", () => {
39
- delete process.env.IS_CONTAINERIZED;
40
- expect(getDaemonRuntimeMode()).toBe("bare-metal");
41
- });
42
-
43
- test("returns 'bare-metal' when IS_CONTAINERIZED=false", () => {
44
- process.env.IS_CONTAINERIZED = "false";
45
- expect(getDaemonRuntimeMode()).toBe("bare-metal");
46
- });
47
-
48
- test("returns 'bare-metal' when IS_CONTAINERIZED=0", () => {
49
- process.env.IS_CONTAINERIZED = "0";
50
- expect(getDaemonRuntimeMode()).toBe("bare-metal");
51
- });
52
-
53
- test("returns 'bare-metal' when IS_CONTAINERIZED is an empty string", () => {
54
- process.env.IS_CONTAINERIZED = "";
55
- expect(getDaemonRuntimeMode()).toBe("bare-metal");
56
- });
57
-
58
- test("returns 'bare-metal' for arbitrary non-truthy values", () => {
59
- process.env.IS_CONTAINERIZED = "yes";
60
- expect(getDaemonRuntimeMode()).toBe("bare-metal");
61
- });
62
- });
@@ -1,38 +0,0 @@
1
- /**
2
- * Daemon runtime-mode detection.
3
- *
4
- * Exposes a single well-named helper `getDaemonRuntimeMode()` that returns
5
- * `"docker"` when the daemon is running inside a container and
6
- * `"bare-metal"` otherwise.
7
- *
8
- * Under the hood this delegates to `getIsContainerized()` from the env
9
- * registry, which accepts the standard truthy values for `IS_CONTAINERIZED`
10
- * (`"true"` or `"1"`). Keeping the check in the registry avoids duplicating
11
- * the env-parsing semantics across modules.
12
- *
13
- * The mode-named API (rather than a boolean) exists to make downstream
14
- * switch/branch code read naturally — e.g. `if (mode === "docker") { ... }`
15
- * is clearer than `if (isContainerized) { ... }` when the behavior depends
16
- * on the specific deployment shape, and it leaves room for additional
17
- * runtime modes in the future without renaming every callsite.
18
- *
19
- * The `DaemonRuntimeMode` type is declared here, alongside the resolver
20
- * that produces it. `"docker"` describes a daemon running inside a
21
- * container; `"bare-metal"` describes a daemon running directly on the
22
- * host.
23
- */
24
-
25
- import { getIsContainerized } from "../config/env-registry.js";
26
-
27
- export type DaemonRuntimeMode = "bare-metal" | "docker";
28
-
29
- /**
30
- * Returns the deployment mode the daemon is currently running under.
31
- *
32
- * - `"docker"` when `IS_CONTAINERIZED` is set to a truthy value
33
- * (`"true"` or `"1"`).
34
- * - `"bare-metal"` otherwise.
35
- */
36
- export function getDaemonRuntimeMode(): DaemonRuntimeMode {
37
- return getIsContainerized() ? "docker" : "bare-metal";
38
- }