@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.
- package/core/src/attachment/bytes-provider.ts +65 -0
- package/core/src/content-range-constants.ts +19 -0
- package/core/src/content-range.test.ts +127 -0
- package/core/src/content-range.ts +105 -8
- package/core/src/core.test.ts +66 -4
- package/core/src/expand.ts +11 -3
- package/core/src/lede.test.ts +96 -0
- package/core/src/mcp-manifest.test.ts +200 -0
- package/core/src/mcp-manifest.ts +736 -0
- package/core/src/mcp.ts +357 -607
- package/core/src/notes.ts +69 -10
- package/core/src/vault-projection.ts +17 -10
- package/package.json +1 -1
- package/src/attachment-bytes.ts +68 -0
- package/src/attachment-tickets.test.ts +126 -1
- package/src/attachment-tickets.ts +77 -1
- package/src/auth-hub-jwt.test.ts +118 -1
- package/src/auth.ts +64 -0
- package/src/config.test.ts +16 -0
- package/src/config.ts +17 -0
- package/src/embedding/select.test.ts +58 -30
- package/src/embedding/select.ts +62 -21
- package/src/embeddings-routes.test.ts +188 -0
- package/src/embeddings-routes.ts +178 -0
- package/src/live-frame-parity.test.ts +21 -0
- package/src/mcp-http.ts +20 -3
- package/src/mcp-tools.ts +15 -3
- package/src/oauth-discovery.ts +31 -0
- package/src/read-attachment.test.ts +436 -0
- package/src/routes.ts +80 -4
- package/src/routing.test.ts +358 -4
- package/src/routing.ts +163 -23
- package/src/scopes.ts +22 -0
- package/src/server.ts +17 -8
- package/src/storage.test.ts +200 -1
- package/src/subscriptions.ts +13 -1
- package/src/transcription-worker.test.ts +151 -0
- package/src/transcription-worker.ts +113 -52
- package/src/vault-embeddings-capability.test.ts +28 -6
- package/src/vault-store-embedding-wiring.test.ts +25 -16
- package/src/vault-store.ts +47 -16
- package/src/vault.test.ts +26 -13
- package/src/ws-server.ts +9 -1
- package/src/ws-subscribe.test.ts +87 -0
- package/src/ws-subscribe.ts +25 -6
- package/web/ui/dist/assets/index-NvwxfZcu.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-CJ6NtIwh.js +0 -61
package/src/config.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
58
|
+
expect(provider!.model).toBe("nomic-embed-text");
|
|
55
59
|
});
|
|
56
60
|
});
|
|
57
61
|
|
|
58
|
-
describe("
|
|
59
|
-
test("
|
|
60
|
-
expect(
|
|
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
|
|
64
|
-
expect(
|
|
65
|
-
expect(
|
|
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
|
|
69
|
-
expect(
|
|
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("
|
|
73
|
-
expect(
|
|
74
|
-
expect(
|
|
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 —
|
|
80
|
-
test("
|
|
81
|
-
|
|
82
|
-
|
|
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("
|
|
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
|
|
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
|
});
|
package/src/embedding/select.ts
CHANGED
|
@@ -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
|
-
* **
|
|
21
|
-
* `buildEmbeddingProvider` returns
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* (see `
|
|
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
|
-
* `
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
|
68
|
-
|
|
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
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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(
|
|
82
|
-
|
|
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
|
+
});
|