@openparachute/vault 0.7.3-rc.9 → 0.7.4-rc.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.
Files changed (48) hide show
  1. package/core/src/attachment/bytes-provider.ts +65 -0
  2. package/core/src/content-range-constants.ts +19 -0
  3. package/core/src/content-range.test.ts +127 -0
  4. package/core/src/content-range.ts +105 -8
  5. package/core/src/core.test.ts +66 -4
  6. package/core/src/expand.ts +11 -3
  7. package/core/src/lede.test.ts +96 -0
  8. package/core/src/mcp-manifest.test.ts +200 -0
  9. package/core/src/mcp-manifest.ts +736 -0
  10. package/core/src/mcp.ts +357 -607
  11. package/core/src/notes.ts +69 -10
  12. package/core/src/vault-projection.ts +17 -10
  13. package/package.json +1 -1
  14. package/src/attachment-bytes.ts +68 -0
  15. package/src/attachment-tickets.test.ts +126 -1
  16. package/src/attachment-tickets.ts +77 -1
  17. package/src/auth-hub-jwt.test.ts +118 -1
  18. package/src/auth.ts +64 -0
  19. package/src/config.test.ts +16 -0
  20. package/src/config.ts +17 -0
  21. package/src/embedding/select.test.ts +58 -30
  22. package/src/embedding/select.ts +62 -21
  23. package/src/embeddings-routes.test.ts +188 -0
  24. package/src/embeddings-routes.ts +178 -0
  25. package/src/live-frame-parity.test.ts +21 -0
  26. package/src/mcp-http.ts +20 -3
  27. package/src/mcp-tools.ts +15 -3
  28. package/src/oauth-discovery.ts +31 -0
  29. package/src/read-attachment.test.ts +436 -0
  30. package/src/routes.ts +80 -4
  31. package/src/routing.test.ts +358 -4
  32. package/src/routing.ts +163 -23
  33. package/src/scopes.ts +22 -0
  34. package/src/server.ts +17 -8
  35. package/src/storage.test.ts +200 -1
  36. package/src/subscriptions.ts +13 -1
  37. package/src/transcription-worker.test.ts +151 -0
  38. package/src/transcription-worker.ts +113 -52
  39. package/src/vault-embeddings-capability.test.ts +28 -6
  40. package/src/vault-store-embedding-wiring.test.ts +25 -16
  41. package/src/vault-store.ts +47 -16
  42. package/src/vault.test.ts +26 -13
  43. package/src/ws-server.ts +9 -1
  44. package/src/ws-subscribe.test.ts +87 -0
  45. package/src/ws-subscribe.ts +25 -6
  46. package/web/ui/dist/assets/index-NvwxfZcu.js +61 -0
  47. package/web/ui/dist/index.html +1 -1
  48. package/web/ui/dist/assets/index-CJ6NtIwh.js +0 -61
@@ -344,6 +344,22 @@ describe("config", () => {
344
344
  expect(readGlobalConfig().autostart).toBe(false);
345
345
  });
346
346
 
347
+ test("round-trips embeddings_enabled: true|false (semantic-search opt-in, 0.7.3)", () => {
348
+ // Absent means off — semantic search is opt-in as of 0.7.3. The key is
349
+ // only persisted when explicitly set (the self-host settings toggle).
350
+ writeGlobalConfig({ port: 1940 });
351
+ expect(readGlobalConfig().embeddings_enabled).toBeUndefined();
352
+
353
+ // Explicit true — the operator turned semantic search on without editing
354
+ // the env file; getSharedEmbeddingProvider threads this in as the default.
355
+ writeGlobalConfig({ port: 1940, embeddings_enabled: true });
356
+ expect(readGlobalConfig().embeddings_enabled).toBe(true);
357
+
358
+ // Explicit false — persisted opt-out (distinct from absent for clarity).
359
+ writeGlobalConfig({ port: 1940, embeddings_enabled: false });
360
+ expect(readGlobalConfig().embeddings_enabled).toBe(false);
361
+ });
362
+
347
363
  test("round-trips default_mirror: internal|off", () => {
348
364
  // Absent: createVault falls back to the in-code default ("internal" —
349
365
  // backup-on-by-default). The knob is only persisted when explicitly set.
package/src/config.ts CHANGED
@@ -387,6 +387,18 @@ export interface GlobalConfig {
387
387
  /** Master toggle. Default false; the worker is a no-op when unset. */
388
388
  enabled?: boolean;
389
389
  };
390
+ /**
391
+ * Semantic-search opt-in (0.7.3, Aaron-ratified). Semantic search is OFF
392
+ * by default; set this to `true` to build the embedding provider and turn
393
+ * on embed-on-write + the backfill sweep. This is the persisted,
394
+ * settings-surface-friendly toggle a self-host operator flips without
395
+ * editing the env file. The `EMBEDDINGS_ENABLED` env var is the low-level
396
+ * OVERRIDE (`true`/`1` forces on, `false`/`0` forces off, anything else
397
+ * defers here) — see `src/embedding/select.ts`'s `resolveEmbeddingsEnabled`.
398
+ * Unset ⇒ off. Takes effect on server (re)start, since the process shares
399
+ * one embedding provider resolved at boot.
400
+ */
401
+ embeddings_enabled?: boolean;
390
402
  }
391
403
 
392
404
  // ---------------------------------------------------------------------------
@@ -1308,6 +1320,7 @@ export function readGlobalConfig(): GlobalConfig {
1308
1320
  const discoveryMatch = yaml.match(/^discovery:\s*(enabled|disabled)/m);
1309
1321
  const autostartMatch = yaml.match(/^autostart:\s*(true|false)/m);
1310
1322
  const autoCreateMatch = yaml.match(/^auto_create:\s*(true|false)/m);
1323
+ const embeddingsEnabledMatch = yaml.match(/^embeddings_enabled:\s*(true|false)/m);
1311
1324
  const defaultMirrorMatch = yaml.match(/^default_mirror:\s*(internal|off)/m);
1312
1325
  // auto_transcribe block — currently single boolean `enabled` (vault#353).
1313
1326
  // Parsed as a nested 2-space-indent block so future fields can grow under
@@ -1340,6 +1353,9 @@ export function readGlobalConfig(): GlobalConfig {
1340
1353
  if (autoCreateMatch) {
1341
1354
  config.auto_create = autoCreateMatch[1]! === "true";
1342
1355
  }
1356
+ if (embeddingsEnabledMatch) {
1357
+ config.embeddings_enabled = embeddingsEnabledMatch[1]! === "true";
1358
+ }
1343
1359
  if (defaultMirrorMatch) {
1344
1360
  config.default_mirror = defaultMirrorMatch[1]! as "internal" | "off";
1345
1361
  }
@@ -1416,6 +1432,7 @@ export function writeGlobalConfig(config: GlobalConfig): void {
1416
1432
  if (config.discovery) lines.push(`discovery: ${config.discovery}`);
1417
1433
  if (config.autostart !== undefined) lines.push(`autostart: ${config.autostart}`);
1418
1434
  if (config.auto_create !== undefined) lines.push(`auto_create: ${config.auto_create}`);
1435
+ if (config.embeddings_enabled !== undefined) lines.push(`embeddings_enabled: ${config.embeddings_enabled}`);
1419
1436
  if (config.default_mirror) lines.push(`default_mirror: ${config.default_mirror}`);
1420
1437
  if (config.owner_password_hash) {
1421
1438
  lines.push(`owner_password_hash: "${config.owner_password_hash}"`);
@@ -2,7 +2,8 @@ import { describe, test, expect } from "bun:test";
2
2
  import {
3
3
  resolveEmbeddingApiConfig,
4
4
  buildEmbeddingProvider,
5
- embeddingsExplicitlyDisabled,
5
+ embeddingsEnabledEnvOverride,
6
+ resolveEmbeddingsEnabled,
6
7
  DEFAULT_EXTERNAL_EMBEDDING_MODEL,
7
8
  } from "./select.ts";
8
9
  import { ExternalApiEmbeddingProvider } from "./external-api.ts";
@@ -29,71 +30,98 @@ describe("resolveEmbeddingApiConfig", () => {
29
30
  });
30
31
  });
31
32
 
32
- describe("buildEmbeddingProvider — two-tier selection", () => {
33
+ describe("buildEmbeddingProvider — two-tier selection (once ENABLED)", () => {
34
+ // Semantic search is opt-in as of 0.7.3, so every case here enables it
35
+ // (via the env override) before asserting WHICH tier is selected.
33
36
  test("zero-config: no EMBEDDING_API_URL -> the bundled floor (onnx-transformers)", () => {
34
- const provider = buildEmbeddingProvider({});
37
+ const provider = buildEmbeddingProvider({ EMBEDDINGS_ENABLED: "true" });
35
38
  expect(provider).toBeInstanceOf(OnnxTransformersEmbeddingProvider);
36
39
  });
37
40
 
38
41
  test("EMBEDDING_API_URL set -> the config upgrade tier (external-api) wins", () => {
39
- const provider = buildEmbeddingProvider({ EMBEDDING_API_URL: "http://localhost:11434/v1" });
42
+ const provider = buildEmbeddingProvider({ EMBEDDINGS_ENABLED: "true", EMBEDDING_API_URL: "http://localhost:11434/v1" });
40
43
  expect(provider).toBeInstanceOf(ExternalApiEmbeddingProvider);
41
- expect(provider.model).toBe(DEFAULT_EXTERNAL_EMBEDDING_MODEL);
44
+ expect(provider!.model).toBe(DEFAULT_EXTERNAL_EMBEDDING_MODEL);
42
45
  });
43
46
 
44
47
  test("EMBEDDING_MODEL alone (no URL) has no effect — still the bundled floor", () => {
45
- const provider = buildEmbeddingProvider({ EMBEDDING_MODEL: "bge-m3" });
48
+ const provider = buildEmbeddingProvider({ EMBEDDINGS_ENABLED: "true", EMBEDDING_MODEL: "bge-m3" });
46
49
  expect(provider).toBeInstanceOf(OnnxTransformersEmbeddingProvider);
47
50
  });
48
51
 
49
52
  test("an explicit EMBEDDING_MODEL overrides the config-tier default", () => {
50
53
  const provider = buildEmbeddingProvider({
54
+ EMBEDDINGS_ENABLED: "true",
51
55
  EMBEDDING_API_URL: "http://x",
52
56
  EMBEDDING_MODEL: "nomic-embed-text",
53
57
  });
54
- expect(provider.model).toBe("nomic-embed-text");
58
+ expect(provider!.model).toBe("nomic-embed-text");
55
59
  });
56
60
  });
57
61
 
58
- describe("embeddingsExplicitlyDisabled — the EMBEDDINGS_ENABLED off switch (M1)", () => {
59
- test("false only for the literal string \"false\"", () => {
60
- expect(embeddingsExplicitlyDisabled({ EMBEDDINGS_ENABLED: "false" })).toBe(true);
62
+ describe("embeddingsEnabledEnvOverride — the EMBEDDINGS_ENABLED tri-state override", () => {
63
+ test("\"true\"/\"1\" force ON (case-insensitive, trimmed)", () => {
64
+ expect(embeddingsEnabledEnvOverride({ EMBEDDINGS_ENABLED: "true" })).toBe(true);
65
+ expect(embeddingsEnabledEnvOverride({ EMBEDDINGS_ENABLED: "TRUE" })).toBe(true);
66
+ expect(embeddingsEnabledEnvOverride({ EMBEDDINGS_ENABLED: " 1 " })).toBe(true);
61
67
  });
62
68
 
63
- test("case-insensitive and trims whitespace", () => {
64
- expect(embeddingsExplicitlyDisabled({ EMBEDDINGS_ENABLED: "FALSE" })).toBe(true);
65
- expect(embeddingsExplicitlyDisabled({ EMBEDDINGS_ENABLED: " false " })).toBe(true);
69
+ test("\"false\"/\"0\" force OFF (case-insensitive, trimmed)", () => {
70
+ expect(embeddingsEnabledEnvOverride({ EMBEDDINGS_ENABLED: "false" })).toBe(false);
71
+ expect(embeddingsEnabledEnvOverride({ EMBEDDINGS_ENABLED: "FALSE" })).toBe(false);
72
+ expect(embeddingsEnabledEnvOverride({ EMBEDDINGS_ENABLED: " 0 " })).toBe(false);
66
73
  });
67
74
 
68
- test("unset defaults to enabled (not disabled)", () => {
69
- expect(embeddingsExplicitlyDisabled({})).toBe(false);
75
+ test("unset / blank / unrecognized → undefined (defer to the persisted setting)", () => {
76
+ expect(embeddingsEnabledEnvOverride({})).toBeUndefined();
77
+ expect(embeddingsEnabledEnvOverride({ EMBEDDINGS_ENABLED: "" })).toBeUndefined();
78
+ expect(embeddingsEnabledEnvOverride({ EMBEDDINGS_ENABLED: " " })).toBeUndefined();
79
+ expect(embeddingsEnabledEnvOverride({ EMBEDDINGS_ENABLED: "yes" })).toBeUndefined();
80
+ });
81
+ });
82
+
83
+ describe("resolveEmbeddingsEnabled — env override, else persisted, else OFF", () => {
84
+ test("defaults OFF when neither env nor persisted setting is present (opt-in)", () => {
85
+ expect(resolveEmbeddingsEnabled({})).toBe(false);
86
+ expect(resolveEmbeddingsEnabled({}, undefined)).toBe(false);
87
+ });
88
+
89
+ test("persisted setting decides when the env var is absent", () => {
90
+ expect(resolveEmbeddingsEnabled({}, true)).toBe(true);
91
+ expect(resolveEmbeddingsEnabled({}, false)).toBe(false);
92
+ });
93
+
94
+ test("env override wins over the persisted setting in BOTH directions", () => {
95
+ expect(resolveEmbeddingsEnabled({ EMBEDDINGS_ENABLED: "false" }, true)).toBe(false);
96
+ expect(resolveEmbeddingsEnabled({ EMBEDDINGS_ENABLED: "true" }, false)).toBe(true);
70
97
  });
71
98
 
72
- test("\"true\" (and any other value) is enabled — this var is an off switch, not an opt-in", () => {
73
- expect(embeddingsExplicitlyDisabled({ EMBEDDINGS_ENABLED: "true" })).toBe(false);
74
- expect(embeddingsExplicitlyDisabled({ EMBEDDINGS_ENABLED: "0" })).toBe(false);
75
- expect(embeddingsExplicitlyDisabled({ EMBEDDINGS_ENABLED: "no" })).toBe(false);
99
+ test("an unrecognized env value defers to the persisted setting (not a guess)", () => {
100
+ expect(resolveEmbeddingsEnabled({ EMBEDDINGS_ENABLED: "maybe" }, true)).toBe(true);
101
+ expect(resolveEmbeddingsEnabled({ EMBEDDINGS_ENABLED: "maybe" }, false)).toBe(false);
76
102
  });
77
103
  });
78
104
 
79
- describe("buildEmbeddingProvider — EMBEDDINGS_ENABLED=false (M1)", () => {
80
- test("returns undefined even when the config-upgrade tier is otherwise fully configured", () => {
81
- const provider = buildEmbeddingProvider({
82
- EMBEDDINGS_ENABLED: "false",
83
- EMBEDDING_API_URL: "http://localhost:11434/v1",
84
- EMBEDDING_MODEL: "bge-m3",
85
- });
86
- expect(provider).toBeUndefined();
105
+ describe("buildEmbeddingProvider — opt-in gate (0.7.3)", () => {
106
+ test("DEFAULT off: no provider when nothing enables it (unset env, no persisted setting)", () => {
107
+ expect(buildEmbeddingProvider({})).toBeUndefined();
108
+ expect(buildEmbeddingProvider({ EMBEDDING_API_URL: "http://localhost:11434/v1", EMBEDDING_MODEL: "bge-m3" })).toBeUndefined();
87
109
  });
88
110
 
89
- test("returns undefined for the zero-config (bundled-floor) case too", () => {
90
- expect(buildEmbeddingProvider({ EMBEDDINGS_ENABLED: "false" })).toBeUndefined();
111
+ test("EMBEDDINGS_ENABLED=false forces off even with the persisted setting on", () => {
112
+ expect(buildEmbeddingProvider({ EMBEDDINGS_ENABLED: "false" }, { persistedEnabled: true })).toBeUndefined();
91
113
  });
92
114
 
93
- test("EMBEDDINGS_ENABLED=true (or unset) is unaffected — both tiers still resolve normally", () => {
115
+ test("EMBEDDINGS_ENABLED=true (or =1) enables — both tiers then resolve normally", () => {
94
116
  expect(buildEmbeddingProvider({ EMBEDDINGS_ENABLED: "true" })).toBeInstanceOf(OnnxTransformersEmbeddingProvider);
117
+ expect(buildEmbeddingProvider({ EMBEDDINGS_ENABLED: "1" })).toBeInstanceOf(OnnxTransformersEmbeddingProvider);
95
118
  expect(
96
119
  buildEmbeddingProvider({ EMBEDDINGS_ENABLED: "true", EMBEDDING_API_URL: "http://x" }),
97
120
  ).toBeInstanceOf(ExternalApiEmbeddingProvider);
98
121
  });
122
+
123
+ test("the persisted setting alone enables it (the self-host settings toggle, no env var)", () => {
124
+ expect(buildEmbeddingProvider({}, { persistedEnabled: true })).toBeInstanceOf(OnnxTransformersEmbeddingProvider);
125
+ expect(buildEmbeddingProvider({}, { persistedEnabled: false })).toBeUndefined();
126
+ });
99
127
  });
@@ -17,15 +17,28 @@
17
17
  * in-process. This is what makes semantic search work on a fresh
18
18
  * install with no operator action.
19
19
  *
20
- * **Off switch:** `EMBEDDINGS_ENABLED=false` short-circuits BOTH tiers —
21
- * `buildEmbeddingProvider` returns `undefined` regardless of what else is
22
- * configured. Mirrors the `EMBEDDINGS_ENABLED` wrangler var C2 (cloud)
23
- * plans for the same gate. A caller wired against an `undefined` provider
24
- * (see `Store.embeddingProvider`) reports the `embeddings` capability as
20
+ * **Opt-in gate (0.7.3, Aaron-ratified):** semantic search is OFF by
21
+ * default. `buildEmbeddingProvider` returns a provider ONLY when the
22
+ * feature is explicitly enabled — otherwise it returns `undefined` and
23
+ * BOTH tiers are short-circuited. The enable signal is resolved with a
24
+ * two-level precedence (see `resolveEmbeddingsEnabled`):
25
+ *
26
+ * 1. **`EMBEDDINGS_ENABLED` env var** — the low-level override. `true`/`1`
27
+ * forces ON, `false`/`0` forces OFF, anything else (incl. unset)
28
+ * defers to the persisted setting. Mirrors the cloud wrangler var.
29
+ * 2. **Persisted `embeddings_enabled`** (config.yaml, wired in by the
30
+ * caller — see `getSharedEmbeddingProvider`) — the self-host settings
31
+ * toggle, so an operator can turn semantic search on without editing
32
+ * the env file. Defaults OFF when unset.
33
+ *
34
+ * A caller wired against an `undefined` provider (see
35
+ * `Store.embeddingProvider`) reports the `embeddings` capability as
25
36
  * disabled and `semanticSearch` throws `semantic_unavailable` — the exact
26
37
  * same honest-failure path as "no provider configured" (never a silent
27
38
  * keyword fallback). The embed-on-write drain simply has nothing to
28
- * invoke, so it no-ops.
39
+ * invoke, so it no-ops — no hook work, no backfill sweep, and (because the
40
+ * ~270MB `@huggingface/transformers` import is dynamic and only happens
41
+ * inside a real `embed()` call) no model download until a user opts in.
29
42
  *
30
43
  * `EMBEDDING_MODEL` alone (no `EMBEDDING_API_URL`) has no effect — it only
31
44
  * shapes the config-upgrade tier. Resolved per-call (not cached here) so
@@ -58,28 +71,56 @@ export function resolveEmbeddingApiConfig(env: NodeJS.ProcessEnv = process.env):
58
71
  }
59
72
 
60
73
  /**
61
- * `true` only when `EMBEDDINGS_ENABLED` is explicitly the literal string
62
- * `"false"` (case-insensitive, trimmed) — the off switch. Every other
63
- * value, INCLUDING unset, is "enabled": the feature defaults ON (the
64
- * bundled floor tier makes that safe — zero-config still works), and this
65
- * var exists to opt OUT, not to opt in.
74
+ * Parse `EMBEDDINGS_ENABLED` as an explicit tri-state OVERRIDE of the
75
+ * persisted config setting (all matches case-insensitive + trimmed):
76
+ *
77
+ * - `"true"` / `"1"` → `true` (force semantic search ON)
78
+ * - `"false"` / `"0"` → `false` (force it OFF)
79
+ * - unset / blank / anything unrecognized → `undefined` (no opinion —
80
+ * defer to the persisted `embeddings_enabled` setting)
81
+ *
82
+ * Returning `undefined` (rather than guessing) for an unrecognized value is
83
+ * what lets the env var be a true override: only an explicit true/false
84
+ * short-circuits the persisted setting.
85
+ */
86
+ export function embeddingsEnabledEnvOverride(env: NodeJS.ProcessEnv = process.env): boolean | undefined {
87
+ const raw = env.EMBEDDINGS_ENABLED?.trim().toLowerCase();
88
+ if (!raw) return undefined;
89
+ if (raw === "true" || raw === "1") return true;
90
+ if (raw === "false" || raw === "0") return false;
91
+ return undefined;
92
+ }
93
+
94
+ /**
95
+ * The effective enabled state for semantic search. Env override wins; else
96
+ * the persisted config setting; else OFF — semantic search is opt-in as of
97
+ * 0.7.3. Pure so both the provider factory and the "why is it off" hint
98
+ * derive from the same rule.
66
99
  */
67
- export function embeddingsExplicitlyDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
68
- return env.EMBEDDINGS_ENABLED?.trim().toLowerCase() === "false";
100
+ export function resolveEmbeddingsEnabled(
101
+ env: NodeJS.ProcessEnv = process.env,
102
+ persistedEnabled?: boolean,
103
+ ): boolean {
104
+ return embeddingsEnabledEnvOverride(env) ?? persistedEnabled ?? false;
69
105
  }
70
106
 
71
107
  /**
72
108
  * Build the provider the current config selects, or `undefined` when
73
- * `EMBEDDINGS_ENABLED=false` (see the off-switch doc above). Pure factory
74
- * (no caching, no I/O beyond what the provider's own constructor does —
75
- * which is none; both providers lazy-load/lazy-connect on first
76
- * `embed()`), so it's cheap to call repeatedly in tests. Production
77
- * callers should go through the shared singleton
109
+ * semantic search is not enabled (see `resolveEmbeddingsEnabled` — env
110
+ * override, else the persisted `embeddings_enabled` setting, else OFF).
111
+ * Pure factory (no caching, no I/O beyond what the provider's own
112
+ * constructor does — which is none; both providers lazy-load/lazy-connect
113
+ * on first `embed()`), so it's cheap to call repeatedly in tests.
114
+ * Production callers should go through the shared singleton
78
115
  * (`getSharedEmbeddingProvider`) instead of calling this directly, so the
79
- * bundled ONNX model — when selected — loads at most once per process.
116
+ * bundled ONNX model — when selected — loads at most once per process, and
117
+ * so the persisted setting is threaded in.
80
118
  */
81
- export function buildEmbeddingProvider(env: NodeJS.ProcessEnv = process.env): EmbeddingProvider | undefined {
82
- if (embeddingsExplicitlyDisabled(env)) return undefined;
119
+ export function buildEmbeddingProvider(
120
+ env: NodeJS.ProcessEnv = process.env,
121
+ opts?: { persistedEnabled?: boolean },
122
+ ): EmbeddingProvider | undefined {
123
+ if (!resolveEmbeddingsEnabled(env, opts?.persistedEnabled)) return undefined;
83
124
  const config = resolveEmbeddingApiConfig(env);
84
125
  if (config.url) {
85
126
  return new ExternalApiEmbeddingProvider({
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Unit coverage for the semantic-search (embeddings) opt-in toggle surface
3
+ * (0.7.3 fast-follow). Fully sandboxed — a throwaway `PARACHUTE_HOME` so the
4
+ * config read/write path operates on a scratch `config.yaml`, and the running
5
+ * "active" state is injected (never touches the real process-shared provider
6
+ * memo or downloads a model).
7
+ *
8
+ * The snapshot builder is pure over (env, persisted, active) so most of the
9
+ * matrix (env override wins, restart-required gap, default-off) is exercised
10
+ * without any I/O; the handler tests verify the config write path + 400s.
11
+ */
12
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
13
+ import { mkdirSync, rmSync } from "fs";
14
+ import { join } from "path";
15
+ import { tmpdir } from "os";
16
+ import { readGlobalConfig, writeGlobalConfig } from "./config.ts";
17
+ import {
18
+ buildEmbeddingsSnapshot,
19
+ handleEmbeddingsGet,
20
+ handleEmbeddingsPut,
21
+ EMBEDDING_MODEL_DOWNLOAD_MB,
22
+ type EmbeddingsSettingsSnapshot,
23
+ } from "./embeddings-routes.ts";
24
+
25
+ let tmpHome: string;
26
+ let prevHome: string | undefined;
27
+
28
+ beforeEach(() => {
29
+ tmpHome = join(tmpdir(), `vault-embed-routes-${Date.now()}-${Math.random().toString(36).slice(2)}`);
30
+ mkdirSync(join(tmpHome, "vault"), { recursive: true });
31
+ prevHome = process.env.PARACHUTE_HOME;
32
+ process.env.PARACHUTE_HOME = tmpHome;
33
+ // Seed a config.yaml so readGlobalConfig has a file (port only — embeddings unset).
34
+ writeGlobalConfig({ port: 1940 });
35
+ });
36
+
37
+ afterEach(() => {
38
+ if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
39
+ else process.env.PARACHUTE_HOME = prevHome;
40
+ rmSync(tmpHome, { recursive: true, force: true });
41
+ });
42
+
43
+ // An env with no EMBEDDINGS_ENABLED — the common self-host case.
44
+ const NO_ENV: NodeJS.ProcessEnv = {};
45
+
46
+ describe("buildEmbeddingsSnapshot", () => {
47
+ test("default: persisted unset, no env, not active → all off, no restart", () => {
48
+ const snap = buildEmbeddingsSnapshot({ env: NO_ENV, persistedEnabled: undefined, active: false });
49
+ expect(snap).toEqual({
50
+ enabled: false,
51
+ env_override: null,
52
+ env_forced: false,
53
+ effective: false,
54
+ active: false,
55
+ restart_required: false,
56
+ model_download_mb: EMBEDDING_MODEL_DOWNLOAD_MB,
57
+ });
58
+ });
59
+
60
+ test("persisted on but not yet active → effective on, restart required", () => {
61
+ const snap = buildEmbeddingsSnapshot({ env: NO_ENV, persistedEnabled: true, active: false });
62
+ expect(snap.enabled).toBe(true);
63
+ expect(snap.effective).toBe(true);
64
+ expect(snap.active).toBe(false);
65
+ expect(snap.restart_required).toBe(true);
66
+ });
67
+
68
+ test("persisted on and active → no restart required", () => {
69
+ const snap = buildEmbeddingsSnapshot({ env: NO_ENV, persistedEnabled: true, active: true });
70
+ expect(snap.restart_required).toBe(false);
71
+ });
72
+
73
+ test("env override ON wins over persisted OFF", () => {
74
+ const snap = buildEmbeddingsSnapshot({
75
+ env: { EMBEDDINGS_ENABLED: "true" },
76
+ persistedEnabled: false,
77
+ active: true,
78
+ });
79
+ expect(snap.enabled).toBe(false); // toggle still reflects persisted
80
+ expect(snap.env_override).toBe(true);
81
+ expect(snap.env_forced).toBe(true);
82
+ expect(snap.effective).toBe(true); // env forces on
83
+ expect(snap.restart_required).toBe(false); // active matches env-forced effective
84
+ });
85
+
86
+ test("env override OFF wins over persisted ON (with a pending restart while still active)", () => {
87
+ const snap = buildEmbeddingsSnapshot({
88
+ env: { EMBEDDINGS_ENABLED: "false" },
89
+ persistedEnabled: true,
90
+ active: true,
91
+ });
92
+ expect(snap.enabled).toBe(true); // persisted intent preserved
93
+ expect(snap.env_override).toBe(false);
94
+ expect(snap.env_forced).toBe(true);
95
+ expect(snap.effective).toBe(false); // env forces off
96
+ expect(snap.active).toBe(true);
97
+ expect(snap.restart_required).toBe(true); // running still on, env wants off
98
+ });
99
+
100
+ test("unrecognized env value defers to persisted (no force)", () => {
101
+ const snap = buildEmbeddingsSnapshot({
102
+ env: { EMBEDDINGS_ENABLED: "maybe" },
103
+ persistedEnabled: true,
104
+ active: false,
105
+ });
106
+ expect(snap.env_override).toBeNull();
107
+ expect(snap.env_forced).toBe(false);
108
+ expect(snap.effective).toBe(true);
109
+ });
110
+ });
111
+
112
+ describe("handleEmbeddingsGet", () => {
113
+ test("reflects the persisted config.yaml value", async () => {
114
+ writeGlobalConfig({ port: 1940, embeddings_enabled: true });
115
+ const res = handleEmbeddingsGet(false);
116
+ expect(res.status).toBe(200);
117
+ const body = (await res.json()) as EmbeddingsSettingsSnapshot;
118
+ expect(body.enabled).toBe(true);
119
+ expect(body.effective).toBe(true);
120
+ expect(body.active).toBe(false);
121
+ expect(body.restart_required).toBe(true);
122
+ });
123
+
124
+ test("unset persisted → enabled false", async () => {
125
+ const res = handleEmbeddingsGet(false);
126
+ const body = (await res.json()) as EmbeddingsSettingsSnapshot;
127
+ expect(body.enabled).toBe(false);
128
+ });
129
+ });
130
+
131
+ describe("handleEmbeddingsPut", () => {
132
+ function putReq(body: unknown): Request {
133
+ return new Request("http://x/vault/default/.parachute/embeddings", {
134
+ method: "PUT",
135
+ headers: { "content-type": "application/json" },
136
+ body: typeof body === "string" ? body : JSON.stringify(body),
137
+ });
138
+ }
139
+
140
+ test("enabling persists embeddings_enabled: true via the config write path", async () => {
141
+ const res = await handleEmbeddingsPut(putReq({ enabled: true }), false);
142
+ expect(res.status).toBe(200);
143
+ const body = (await res.json()) as EmbeddingsSettingsSnapshot;
144
+ expect(body.enabled).toBe(true);
145
+ expect(body.restart_required).toBe(true); // persisted on, injected active=false
146
+ // The setting is actually on disk (config write path reused, not hand-rolled).
147
+ expect(readGlobalConfig().embeddings_enabled).toBe(true);
148
+ });
149
+
150
+ test("disabling persists embeddings_enabled: false", async () => {
151
+ writeGlobalConfig({ port: 1940, embeddings_enabled: true });
152
+ const res = await handleEmbeddingsPut(putReq({ enabled: false }), true);
153
+ expect(res.status).toBe(200);
154
+ expect(readGlobalConfig().embeddings_enabled).toBe(false);
155
+ });
156
+
157
+ test("preserves other config fields on write (round-trips through serialize)", async () => {
158
+ writeGlobalConfig({ port: 1940, discovery: "disabled", autostart: true });
159
+ await handleEmbeddingsPut(putReq({ enabled: true }), false);
160
+ const cfg = readGlobalConfig();
161
+ expect(cfg.embeddings_enabled).toBe(true);
162
+ expect(cfg.discovery).toBe("disabled");
163
+ expect(cfg.autostart).toBe(true);
164
+ expect(cfg.port).toBe(1940);
165
+ });
166
+
167
+ test("non-boolean enabled → 400", async () => {
168
+ const res = await handleEmbeddingsPut(putReq({ enabled: "yes" }), false);
169
+ expect(res.status).toBe(400);
170
+ const body = (await res.json()) as { field?: string };
171
+ expect(body.field).toBe("enabled");
172
+ });
173
+
174
+ test("missing enabled → 400", async () => {
175
+ const res = await handleEmbeddingsPut(putReq({}), false);
176
+ expect(res.status).toBe(400);
177
+ });
178
+
179
+ test("non-object body → 400", async () => {
180
+ const res = await handleEmbeddingsPut(putReq("[]"), false);
181
+ expect(res.status).toBe(400);
182
+ });
183
+
184
+ test("invalid JSON → 400", async () => {
185
+ const res = await handleEmbeddingsPut(putReq("{not json"), false);
186
+ expect(res.status).toBe(400);
187
+ });
188
+ });