@openparachute/vault 0.7.9-rc.6 → 0.7.9-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -227,3 +227,60 @@ describe("vault#524 history retention and blob integrity", () => {
227
227
  expect(await store.getNote(n.id)).toBeNull();
228
228
  });
229
229
  });
230
+
231
+ describe("vault#743 whole-blob integrity", () => {
232
+ it("reads valid whole blobs including empty and multibyte text", async () => {
233
+ const empty = await store.createNote("placeholder");
234
+ await store.updateNote(empty.id, { content: "" });
235
+ await store.updateNote(empty.id, { content: "final" });
236
+ expect((await store.getNoteVersion(empty.id, 1))!.content).toBe("");
237
+
238
+ const multi = await store.createNote("é".repeat(1000));
239
+ await store.updateNote(multi.id, { content: "next" });
240
+ expect((await store.getNoteVersion(multi.id, 0))!.content).toBe("é".repeat(1000));
241
+
242
+ const plain = await store.createNote("whole body");
243
+ await store.updateNote(plain.id, { content: "updated" });
244
+ expect((await store.getNoteVersion(plain.id, 0))!.content).toBe("whole body");
245
+ });
246
+ it("rejects a corrupted whole blob on direct read", async () => {
247
+ const n = await store.createNote("original");
248
+ await store.updateNote(n.id, { content: "live" });
249
+ const hash = (await store.listNoteVersions(n.id))[0]!.content_hash;
250
+ db.prepare("UPDATE note_blobs SET content = ? WHERE hash = ?").run(
251
+ "tampered",
252
+ hash,
253
+ );
254
+ expect(() => history!.readBlobContent(db, hash!)).toThrow(
255
+ history!.HistoryDeltaOrphanError,
256
+ );
257
+ });
258
+ it("rejects a corrupted whole blob through version read", async () => {
259
+ const n = await store.createNote("original");
260
+ await store.updateNote(n.id, { content: "live" });
261
+ const hash = (await store.listNoteVersions(n.id))[0]!.content_hash;
262
+ db.prepare("UPDATE note_blobs SET content = ? WHERE hash = ?").run(
263
+ "tampered",
264
+ hash,
265
+ );
266
+ await expect(store.getNoteVersion(n.id, 0)).rejects.toMatchObject({
267
+ code: "HISTORY_UNRECOVERABLE",
268
+ reason: "delta_orphan",
269
+ });
270
+ });
271
+ it("rejects restoring a corrupted whole blob and leaves live note and history unchanged", async () => {
272
+ const n = await store.createNote("original");
273
+ await store.updateNote(n.id, { content: "live" });
274
+ const versionsBefore = await store.listNoteVersions(n.id);
275
+ const hash = versionsBefore[0]!.content_hash;
276
+ db.prepare("UPDATE note_blobs SET content = ? WHERE hash = ?").run(
277
+ "tampered",
278
+ hash,
279
+ );
280
+ await expect(store.restoreNoteVersion(n.id, 0, {})).rejects.toMatchObject({
281
+ code: "HISTORY_UNRECOVERABLE",
282
+ });
283
+ expect((await store.getNote(n.id))!.content).toBe("live");
284
+ expect(await store.listNoteVersions(n.id)).toEqual(versionsBefore);
285
+ });
286
+ });
@@ -438,8 +438,11 @@ interface BlobReadRow {
438
438
  function materialise(db: Database, hash: string, row: BlobReadRow): string | null {
439
439
  if (row.content === null)
440
440
  return null;
441
- if (row.blob_encoding === null)
441
+ if (row.blob_encoding === null) {
442
+ if (hashContent(row.content) !== hash)
443
+ throw new HistoryDeltaOrphanError(hash, row.delta_of, "identity_mismatch");
442
444
  return row.content;
445
+ }
443
446
  if (row.blob_encoding !== "fossil-delta")
444
447
  throw new HistoryDeltaOrphanError(hash, row.delta_of, "unknown_encoding");
445
448
  const base = row.delta_of ? db.prepare("SELECT content, encoding FROM note_blobs WHERE hash = ?").get(row.delta_of) as {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.9-rc.6",
3
+ "version": "0.7.9-rc.7",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -29,6 +29,7 @@ const { handleScopedMcp } = await import("./mcp-http.ts");
29
29
  const { getServerInstruction } = await import("./mcp-tools.ts");
30
30
  const { writeVaultConfig } = await import("./config.ts");
31
31
  const { getVaultStore } = await import("./vault-store.ts");
32
+ const { clearScribeUrlCache } = await import("./scribe-discovery.ts");
32
33
  const {
33
34
  getSharedAttachmentTicketProvider,
34
35
  InProcessAttachmentTicketProvider,
@@ -275,33 +276,54 @@ describe("attachment tickets — upload lifecycle", () => {
275
276
  // exactly what a new box looks like. The upload must not come back looking
276
277
  // like an ordinary file: it carries a `failed` status and an actionable
277
278
  // reason, so the state is visible in the API and the admin SPA.
278
- test("audio + auto-transcribe on + NO provider → attachment records the failure, not silence", async () => {
279
- const vaultName = freshVault("tickets-no-provider");
280
- const store = getVaultStore(vaultName);
281
- const note = await store.createNote("# Voice memo\n", { path: "memo-noprov" });
282
-
283
- const mint = await callTool(vaultName, "request-attachment-upload", {
284
- note: note.id,
285
- filename: "memo.webm",
286
- size_bytes: 4,
287
- // NO `transcribe: true` — this is the AUTO path, the one that used to
288
- // silently do nothing.
289
- });
290
- const res = await routeReq(
291
- new Request(mint.url, {
292
- method: "PUT",
293
- headers: { "content-type": "audio/webm" },
294
- body: new Uint8Array([1, 2, 3, 4]),
295
- }),
296
- );
297
- expect(res.status).toBe(201);
298
- const attachment = (await res.json()) as any;
299
- expect(attachment.metadata.transcribe_status).toBe("failed");
300
- expect(attachment.metadata.transcribe_error).toMatch(/no transcription provider configured/i);
301
- // The reason has to be actionable — naming both routes out of it.
302
- expect(attachment.metadata.transcribe_error).toMatch(/TRANSCRIPTION_PROVIDER/);
303
- expect(attachment.metadata.transcribe_error).toMatch(/SCRIBE_URL/);
304
- expect(attachment.metadata.transcribe_origin).toBe("auto");
279
+ //
280
+ // Fixture: select the remote provider so the presence/absence of a local
281
+ // whisper-cpp install on the host cannot change the message. With no
282
+ // SCRIBE_URL configured, auto-transcribe is still unavailable and reports
283
+ // the same actionable no-provider error on every box.
284
+ test("audio + auto-transcribe on + scribe-http without URL → attachment records the failure, not silence", async () => {
285
+ const prevProvider = process.env.TRANSCRIPTION_PROVIDER;
286
+ const prevScribeUrl = process.env.SCRIBE_URL;
287
+ process.env.TRANSCRIPTION_PROVIDER = "scribe-http";
288
+ delete process.env.SCRIBE_URL;
289
+ clearScribeUrlCache();
290
+ try {
291
+ const vaultName = freshVault("tickets-no-provider");
292
+ const store = getVaultStore(vaultName);
293
+ const note = await store.createNote("# Voice memo\n", { path: "memo-noprov" });
294
+
295
+ const mint = await callTool(vaultName, "request-attachment-upload", {
296
+ note: note.id,
297
+ filename: "memo.webm",
298
+ size_bytes: 4,
299
+ // NO `transcribe: true` — this is the AUTO path, the one that used to
300
+ // silently do nothing.
301
+ });
302
+ const res = await routeReq(
303
+ new Request(mint.url, {
304
+ method: "PUT",
305
+ headers: { "content-type": "audio/webm" },
306
+ body: new Uint8Array([1, 2, 3, 4]),
307
+ }),
308
+ );
309
+ expect(res.status).toBe(201);
310
+ const attachment = (await res.json()) as any;
311
+ expect(attachment.metadata.transcribe_status).toBe("failed");
312
+ expect(attachment.metadata.transcribe_error).toMatch(/no transcription provider configured/i);
313
+ // The reason has to be actionable — naming both routes out of it.
314
+ expect(attachment.metadata.transcribe_error).toMatch(/TRANSCRIPTION_PROVIDER/);
315
+ expect(attachment.metadata.transcribe_error).toMatch(/SCRIBE_URL/);
316
+ expect(attachment.metadata.transcribe_origin).toBe("auto");
317
+ const unchanged = await store.getNote(note.id);
318
+ expect(unchanged!.content).toBe("# Voice memo\n");
319
+ expect((unchanged!.metadata as any)?.transcribe_stub).toBeUndefined();
320
+ } finally {
321
+ if (prevProvider === undefined) delete process.env.TRANSCRIPTION_PROVIDER;
322
+ else process.env.TRANSCRIPTION_PROVIDER = prevProvider;
323
+ if (prevScribeUrl === undefined) delete process.env.SCRIBE_URL;
324
+ else process.env.SCRIBE_URL = prevScribeUrl;
325
+ clearScribeUrlCache();
326
+ }
305
327
  });
306
328
 
307
329
  // The counter-case: turning auto-transcribe OFF must stay silent. The
@@ -0,0 +1,82 @@
1
+ import { test, expect } from "bun:test";
2
+ import { writeVaultConfig } from "./config.ts";
3
+ import { getVaultStore, defaultHookRegistry } from "./vault-store.ts";
4
+ import { handleNotes } from "./routes.ts";
5
+ import { handleScopedMcp } from "./mcp-http.ts";
6
+ import { route } from "./routing.ts";
7
+ import { registerTranscriptionHook } from "./transcription-worker.ts";
8
+ import { getTranscriptionWorker, getTranscriptionWorkerProvider, setTranscriptionWorker } from "./transcription-registry.ts";
9
+
10
+ // Fake the wired worker, not module imports or real ASR. Exercise both upload
11
+ // doors and the actual attachment-created hook without network/subprocess work.
12
+ for (const door of ["rest", "ticket"] as const) {
13
+ for (const mode of ["local", "remote", "missing", "mismatch", "unready", "disabled", "explicit", "non-audio"] as const) {
14
+ test(`${door}: ${mode} automatic worker eligibility`, async () => {
15
+ const previous = { provider: process.env.TRANSCRIPTION_PROVIDER, url: process.env.SCRIBE_URL, model: process.env.TRANSCRIPTION_MODEL, assets: process.env.ASSETS_DIR };
16
+ const oldWorker = getTranscriptionWorker();
17
+ const oldProvider = getTranscriptionWorkerProvider();
18
+ const vault = `auto-worker-${crypto.randomUUID()}`;
19
+ const kicks: string[] = [];
20
+ const worker = { stop: async () => {}, tick: async () => 0,
21
+ kick: async (_vault: string, attachment: { id: string }) => { kicks.push(attachment.id); } };
22
+ writeVaultConfig({ name: vault, api_keys: [], created_at: new Date().toISOString(),
23
+ auto_transcribe: { enabled: mode !== "disabled" && mode !== "explicit" } });
24
+ const store = getVaultStore(vault);
25
+ const unregister = registerTranscriptionHook(defaultHookRegistry, worker, (s) => s === store ? vault : undefined);
26
+ try {
27
+ // Sibling files may set a process-global storage root. These uploads
28
+ // belong only to this unique vault, not that shared test directory.
29
+ delete process.env.ASSETS_DIR;
30
+ process.env.TRANSCRIPTION_PROVIDER = mode === "remote" ? "scribe-http" : mode === "unready" ? "whisper-cpp" : "transcribe-cpp";
31
+ // A stale URL must never make an absent/mismatched local worker ready.
32
+ if (mode === "local") delete process.env.SCRIBE_URL;
33
+ else process.env.SCRIBE_URL = "http://unused.invalid";
34
+ if (mode === "unready") process.env.TRANSCRIPTION_MODEL = "not-a-real-model";
35
+ setTranscriptionWorker(mode === "missing" ? null : worker,
36
+ mode === "mismatch" ? "scribe-http" : process.env.TRANSCRIPTION_PROVIDER);
37
+ const note = await store.createNote("original body", { path: "memo" });
38
+ const mime = mode === "non-audio" ? "image/png" : "audio/wav";
39
+ let response: Response;
40
+ if (door === "rest") {
41
+ response = await handleNotes(new Request(`http://localhost/notes/${note.id}/attachments`, {
42
+ method: "POST", headers: { "content-type": "application/json" },
43
+ body: JSON.stringify({ path: "memos/audio.wav", mimeType: mime,
44
+ ...(mode === "explicit" ? { transcribe: true } : {}) }),
45
+ }), store, `/${note.id}/attachments`, vault);
46
+ } else {
47
+ const mint = await handleScopedMcp(new Request(`http://localhost/vault/${vault}/mcp`, {
48
+ method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
49
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: {
50
+ name: "request-attachment-upload", arguments: { note: note.id, filename: "memo.wav", size_bytes: 4,
51
+ mime_type: mime, ...(mode === "explicit" ? { transcribe: true } : {}) },
52
+ } }),
53
+ }), vault, { permission: "full", scopes: ["vault:read", "vault:write"], legacyDerived: false, scoped_tags: null } as any);
54
+ const rpc = await mint.json() as any;
55
+ expect(rpc.error).toBeUndefined();
56
+ const ticket = JSON.parse(rpc.result.content[0].text);
57
+ const req = new Request(ticket.url, { method: "PUT", headers: { "content-type": mime }, body: new Uint8Array([1, 2, 3, 4]) });
58
+ response = await route(req, new URL(req.url).pathname);
59
+ }
60
+ expect(response.status).toBe(201);
61
+ const attachment = await response.json() as any;
62
+ await defaultHookRegistry.drain();
63
+ const eligible = ["local", "remote", "explicit"].includes(mode);
64
+ const unavailable = ["missing", "mismatch", "unready"].includes(mode);
65
+ expect(attachment.metadata?.transcribe_status).toBe(eligible ? "pending" : unavailable ? "failed" : undefined);
66
+ expect(kicks).toEqual(eligible ? [attachment.id] : []);
67
+ if (unavailable) expect(attachment.metadata.transcribe_error).toContain("transcription status");
68
+ if (eligible || unavailable) expect(attachment.metadata.transcribe_origin).toBe(mode === "explicit" ? "legacy" : "auto");
69
+ const current = await store.getNote(note.id);
70
+ expect(current!.content).toBe("original body");
71
+ if (mode !== "explicit") expect((current!.metadata as any)?.transcribe_stub).toBeUndefined();
72
+ } finally {
73
+ await defaultHookRegistry.drain();
74
+ unregister();
75
+ setTranscriptionWorker(oldWorker, oldProvider);
76
+ for (const [key, value] of [["TRANSCRIPTION_PROVIDER", previous.provider], ["SCRIBE_URL", previous.url], ["TRANSCRIPTION_MODEL", previous.model], ["ASSETS_DIR", previous.assets]] as const) {
77
+ if (value === undefined) delete process.env[key]; else process.env[key] = value;
78
+ }
79
+ }
80
+ });
81
+ }
82
+ }
@@ -1,388 +1,140 @@
1
- /**
2
- * Auto-transcribe gating decisions (vault#353).
3
- *
4
- * Three independent guards: mime-type prefix, enabled toggle, scribe URL
5
- * present. Pure function — exercise the truth table.
6
- */
7
-
8
1
  import { describe, test, expect } from "bun:test";
9
- import {
10
- _resetNoProviderWarnForTest,
11
- classifyAutoTranscribe,
12
- NO_PROVIDER_ERROR,
13
- noProviderErrorFor,
14
- shouldAutoTranscribe,
15
- warnNoTranscriptionProvider,
16
- } from "./auto-transcribe.ts";
17
-
18
- function readGlobalConfig(enabled: boolean | undefined) {
19
- return () => ({
20
- port: 1940,
21
- ...(enabled !== undefined ? { auto_transcribe: { enabled } } : {}),
22
- }) as any;
23
- }
24
-
25
- describe("shouldAutoTranscribe", () => {
26
- const scribePresent = () => "http://127.0.0.1:1943";
27
- const scribeAbsent = () => undefined;
28
-
29
- test("triggers on audio/* mime-type when enabled + scribe reachable", () => {
30
- expect(shouldAutoTranscribe("audio/wav", {
31
- readGlobalConfigImpl: readGlobalConfig(true),
32
- getCachedScribeUrlImpl: scribePresent,
33
- })).toBe(true);
34
- });
35
-
36
- test("triggers on audio/mp4 (m4a)", () => {
37
- expect(shouldAutoTranscribe("audio/mp4", {
38
- readGlobalConfigImpl: readGlobalConfig(true),
39
- getCachedScribeUrlImpl: scribePresent,
40
- })).toBe(true);
41
- });
42
-
43
- test("triggers on audio/webm", () => {
44
- expect(shouldAutoTranscribe("audio/webm", {
45
- readGlobalConfigImpl: readGlobalConfig(true),
46
- getCachedScribeUrlImpl: scribePresent,
47
- })).toBe(true);
48
- });
49
-
50
- test("triggers case-insensitively (AUDIO/WAV)", () => {
51
- expect(shouldAutoTranscribe("AUDIO/WAV", {
52
- readGlobalConfigImpl: readGlobalConfig(true),
53
- getCachedScribeUrlImpl: scribePresent,
54
- })).toBe(true);
55
- });
56
-
57
- test("skips non-audio mime-types (image/png, application/pdf, video/mp4)", () => {
58
- expect(shouldAutoTranscribe("image/png", {
59
- readGlobalConfigImpl: readGlobalConfig(true),
60
- getCachedScribeUrlImpl: scribePresent,
61
- })).toBe(false);
62
- expect(shouldAutoTranscribe("application/pdf", {
63
- readGlobalConfigImpl: readGlobalConfig(true),
64
- getCachedScribeUrlImpl: scribePresent,
65
- })).toBe(false);
66
- expect(shouldAutoTranscribe("video/mp4", {
67
- readGlobalConfigImpl: readGlobalConfig(true),
68
- getCachedScribeUrlImpl: scribePresent,
69
- })).toBe(false);
70
- });
71
-
72
- test("skips when enabled is false (default off)", () => {
73
- expect(shouldAutoTranscribe("audio/wav", {
74
- readGlobalConfigImpl: readGlobalConfig(false),
75
- getCachedScribeUrlImpl: scribePresent,
76
- })).toBe(false);
77
- });
78
-
79
- test("fires when enabled is unset — unset config means ON", () => {
80
- // Default behavior (no `auto_transcribe` block in config) is opt-out:
81
- // once an operator has scribe reachable, audio attachments transcribe
82
- // automatically. Operators wanting it OFF set
83
- // `auto_transcribe.enabled: false` explicitly. Previously default-off;
84
- // flipped to default-on so installing scribe is the only opt-in signal.
85
- expect(shouldAutoTranscribe("audio/wav", {
86
- readGlobalConfigImpl: readGlobalConfig(undefined),
87
- getCachedScribeUrlImpl: scribePresent,
88
- })).toBe(true);
89
- });
90
-
91
- test("skips when scribe URL is undefined (no services.json entry, no env)", () => {
92
- expect(shouldAutoTranscribe("audio/wav", {
93
- readGlobalConfigImpl: readGlobalConfig(true),
94
- getCachedScribeUrlImpl: scribeAbsent,
95
- })).toBe(false);
96
- });
97
-
98
- test("skips when scribe URL is empty string", () => {
99
- expect(shouldAutoTranscribe("audio/wav", {
100
- readGlobalConfigImpl: readGlobalConfig(true),
101
- getCachedScribeUrlImpl: () => "",
102
- })).toBe(false);
103
- });
104
-
105
- test("skips on garbage mime-type input", () => {
106
- expect(shouldAutoTranscribe("", {
107
- readGlobalConfigImpl: readGlobalConfig(true),
108
- getCachedScribeUrlImpl: scribePresent,
109
- })).toBe(false);
110
- expect(shouldAutoTranscribe("not-a-mime", {
111
- readGlobalConfigImpl: readGlobalConfig(true),
112
- getCachedScribeUrlImpl: scribePresent,
113
- })).toBe(false);
114
- });
115
-
116
- test("respects enabledOverride when present", () => {
117
- expect(shouldAutoTranscribe("audio/wav", {
118
- readGlobalConfigImpl: readGlobalConfig(false),
119
- getCachedScribeUrlImpl: scribePresent,
120
- enabledOverride: true,
121
- })).toBe(true);
122
- expect(shouldAutoTranscribe("audio/wav", {
123
- readGlobalConfigImpl: readGlobalConfig(true),
124
- getCachedScribeUrlImpl: scribePresent,
125
- enabledOverride: false,
126
- })).toBe(false);
127
- });
128
-
129
- describe("per-vault precedence (per-vault → global → true)", () => {
130
- test("per-vault true wins even when global is false", () => {
131
- expect(shouldAutoTranscribe("audio/wav", {
132
- readGlobalConfigImpl: readGlobalConfig(false),
133
- getCachedScribeUrlImpl: scribePresent,
134
- perVaultEnabled: true,
135
- })).toBe(true);
136
- });
137
-
138
- test("per-vault false wins even when global is true", () => {
139
- // The whole point: linking scribe to vault X (perVault true) elsewhere
140
- // must not force-on a vault that set its own false.
141
- expect(shouldAutoTranscribe("audio/wav", {
142
- readGlobalConfigImpl: readGlobalConfig(true),
143
- getCachedScribeUrlImpl: scribePresent,
144
- perVaultEnabled: false,
145
- })).toBe(false);
146
- });
147
-
148
- test("per-vault unset falls back to global", () => {
149
- expect(shouldAutoTranscribe("audio/wav", {
150
- readGlobalConfigImpl: readGlobalConfig(true),
151
- getCachedScribeUrlImpl: scribePresent,
152
- perVaultEnabled: undefined,
153
- })).toBe(true);
154
- expect(shouldAutoTranscribe("audio/wav", {
155
- readGlobalConfigImpl: readGlobalConfig(false),
156
- getCachedScribeUrlImpl: scribePresent,
157
- perVaultEnabled: undefined,
158
- })).toBe(false);
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { findModel } from "./transcription/models.ts";
6
+ import { classifyAutoTranscribe, shouldAutoTranscribe, noProviderErrorFor,
7
+ NO_PROVIDER_ERROR, warnNoTranscriptionProvider, _resetNoProviderWarnForTest } from "./auto-transcribe.ts";
8
+ import { getTranscriptionWorker, getTranscriptionWorkerProvider, setTranscriptionWorker } from "./transcription-registry.ts";
9
+
10
+ const config = (enabled?: boolean) => () => ({ port: 1940, auto_transcribe: { enabled } }) as any;
11
+ const ready = () => ({ ready: true, localProvider: "whisper-cpp" });
12
+ const absent = () => ({ ready: false, localProvider: null });
13
+
14
+ describe("automatic transcription eligibility", () => {
15
+ for (const mime of ["audio/wav", "audio/mp4", "audio/webm", "AUDIO/WAV"]) {
16
+ test(`ready worker accepts ${mime}`, () => {
17
+ expect(shouldAutoTranscribe(mime, { readGlobalConfigImpl: config(), providerStateImpl: ready })).toBe(true);
159
18
  });
160
-
161
- test("both per-vault and global unset falls back to true (no regression)", () => {
162
- expect(shouldAutoTranscribe("audio/wav", {
163
- readGlobalConfigImpl: readGlobalConfig(undefined),
164
- getCachedScribeUrlImpl: scribePresent,
165
- perVaultEnabled: undefined,
166
- })).toBe(true);
19
+ }
20
+ for (const mime of ["image/png", "video/mp4", "application/pdf", "", null as any]) {
21
+ test(`non-audio ${mime} never probes readiness`, () => {
22
+ expect(classifyAutoTranscribe(mime, { providerStateImpl: () => { throw Error("unexpected probe"); } }).kind).toBe("not-audio");
167
23
  });
168
-
169
- test("enabledOverride still hard-overrides the per-vault value", () => {
170
- // The explicit caller-opt-in path beats everything.
171
- expect(shouldAutoTranscribe("audio/wav", {
172
- readGlobalConfigImpl: readGlobalConfig(true),
173
- getCachedScribeUrlImpl: scribePresent,
174
- perVaultEnabled: false,
175
- enabledOverride: true,
176
- })).toBe(true);
24
+ }
25
+ for (const global of [undefined, true, false]) {
26
+ for (const vault of [undefined, true, false]) {
27
+ for (const override of [undefined, true, false]) {
28
+ test(`precedence global=${global} vault=${vault} override=${override}`, () => {
29
+ const enabled = override ?? vault ?? global ?? true;
30
+ const d = classifyAutoTranscribe("audio/wav", { readGlobalConfigImpl: config(global),
31
+ perVaultEnabled: vault, enabledOverride: override, providerStateImpl: ready });
32
+ expect(d.kind).toBe(enabled ? "transcribe" : "disabled");
33
+ });
34
+ }
35
+ }
36
+ }
37
+ test("disabled bypasses readiness entirely", () => {
38
+ expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: false,
39
+ providerStateImpl: () => { throw Error("unexpected probe"); } }).kind).toBe("disabled");
40
+ });
41
+ for (const local of [null, "whisper-cpp"]) {
42
+ test(`not ready remains unavailable with installed=${local}`, () => {
43
+ expect(classifyAutoTranscribe("audio/wav", { readGlobalConfigImpl: config(),
44
+ providerStateImpl: () => ({ ready: false, localProvider: local }) })).toEqual({ kind: "unavailable", localProvider: local });
177
45
  });
46
+ }
47
+ test("unavailable is false, not an enqueue", () => {
48
+ expect(shouldAutoTranscribe("audio/wav", { readGlobalConfigImpl: config(), providerStateImpl: absent })).toBe(false);
178
49
  });
179
50
  });
180
51
 
181
- // ---------------------------------------------------------------------------
182
- // vault#643 — "disabled" and "unavailable" are not the same answer.
183
- //
184
- // `shouldAutoTranscribe` collapsed both into `false`, so a box with
185
- // auto-transcribe ON and no reachable provider accepted audio, transcribed
186
- // nothing, wrote no marker, and logged nothing. The attachment was
187
- // indistinguishable from a plain upload.
188
- //
189
- // That is the DEFAULT state of a fresh install: the provider resolves to
190
- // `scribe-http`, nothing sets SCRIBE_URL, and voice memos silently never
191
- // transcribe. The hosted door already marks a terminal state rather than
192
- // skipping quietly (workers/vault/src/vault-do.ts — "voice not enabled for
193
- // this plan" / "monthly voice limit reached"); this is the self-host twin.
194
- // ---------------------------------------------------------------------------
195
- describe("classifyAutoTranscribe (vault#643)", () => {
196
- const audio = "audio/webm";
197
- const withProvider = () => "http://127.0.0.1:1943";
198
- const noProvider = () => undefined;
199
-
200
- test("non-audio → not-audio, whatever else is true", () => {
201
- expect(
202
- classifyAutoTranscribe("image/png", { getCachedScribeUrlImpl: withProvider }).kind,
203
- ).toBe("not-audio");
204
- });
205
-
206
- test("operator turned it off → disabled (silence is CORRECT here)", () => {
207
- expect(
208
- classifyAutoTranscribe(audio, {
209
- perVaultEnabled: false,
210
- getCachedScribeUrlImpl: withProvider,
211
- }).kind,
212
- ).toBe("disabled");
213
- });
214
-
215
- test("enabled + no provider → unavailable, NOT disabled — the whole point", () => {
216
- expect(
217
- classifyAutoTranscribe(audio, {
218
- perVaultEnabled: true,
219
- getCachedScribeUrlImpl: noProvider,
220
- }).kind,
221
- ).toBe("unavailable");
222
- });
223
-
224
- test("a blank provider URL is unavailable, not a provider", () => {
225
- expect(
226
- classifyAutoTranscribe(audio, {
227
- perVaultEnabled: true,
228
- getCachedScribeUrlImpl: () => " ",
229
- }).kind,
230
- ).toBe("unavailable");
231
- });
232
-
233
- test("the default (no toggle set) with no provider is unavailable — the fresh-install case", () => {
234
- expect(
235
- classifyAutoTranscribe(audio, {
236
- readGlobalConfigImpl: (() => ({})) as never,
237
- getCachedScribeUrlImpl: noProvider,
238
- }).kind,
239
- ).toBe("unavailable");
240
- });
241
-
242
- test("enabled + provider → transcribe", () => {
243
- expect(
244
- classifyAutoTranscribe(audio, {
245
- perVaultEnabled: true,
246
- getCachedScribeUrlImpl: withProvider,
247
- }).kind,
248
- ).toBe("transcribe");
249
- });
250
-
251
- test("shouldAutoTranscribe stays a faithful boolean view of the classifier", () => {
252
- for (const opts of [
253
- { perVaultEnabled: false, getCachedScribeUrlImpl: withProvider },
254
- { perVaultEnabled: true, getCachedScribeUrlImpl: noProvider },
255
- { perVaultEnabled: true, getCachedScribeUrlImpl: withProvider },
256
- ]) {
257
- expect(shouldAutoTranscribe(audio, opts)).toBe(
258
- classifyAutoTranscribe(audio, opts).kind === "transcribe",
259
- );
260
- }
261
- });
52
+ test("real gate requires a matching registered worker; stale URL cannot enable a missing local worker", () => {
53
+ const previous = { provider: process.env.TRANSCRIPTION_PROVIDER, url: process.env.SCRIBE_URL };
54
+ const worker = getTranscriptionWorker();
55
+ const provider = getTranscriptionWorkerProvider();
56
+ const fake = { stop: async () => {}, tick: async () => 0, kick: async () => {} };
57
+ try {
58
+ process.env.TRANSCRIPTION_PROVIDER = "transcribe-cpp";
59
+ process.env.SCRIBE_URL = "http://stale.invalid";
60
+ setTranscriptionWorker(null);
61
+ expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("unavailable");
62
+ setTranscriptionWorker(fake, "scribe-http");
63
+ expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("unavailable");
64
+ setTranscriptionWorker(fake, "transcribe-cpp");
65
+ expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("transcribe");
66
+ process.env.TRANSCRIPTION_PROVIDER = "scribe-http";
67
+ expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("unavailable");
68
+ setTranscriptionWorker(fake, "scribe-http");
69
+ expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("transcribe");
70
+ } finally {
71
+ setTranscriptionWorker(worker, provider);
72
+ if (previous.provider === undefined) delete process.env.TRANSCRIPTION_PROVIDER;
73
+ else process.env.TRANSCRIPTION_PROVIDER = previous.provider;
74
+ if (previous.url === undefined) delete process.env.SCRIBE_URL;
75
+ else process.env.SCRIBE_URL = previous.url;
76
+ }
262
77
  });
263
78
 
264
- describe("warnNoTranscriptionProvider throttle (vault#643)", () => {
265
- test("warns once per window — a bulk import gets one line, not a hundred", () => {
266
- _resetNoProviderWarnForTest();
267
- const seen: string[] = [];
268
- const orig = console.warn;
269
- console.warn = (...args: unknown[]) => seen.push(String(args[0]));
270
- try {
271
- let now = 1_000_000;
272
- const clock = () => now;
273
- for (let i = 0; i < 50; i++) warnNoTranscriptionProvider(clock);
274
- expect(seen.length).toBe(1);
275
- expect(seen[0]).toMatch(/no transcription provider configured/i);
276
- // Past the window → one more.
277
- now += 61_000;
278
- warnNoTranscriptionProvider(clock);
279
- expect(seen.length).toBe(2);
280
- } finally {
281
- console.warn = orig;
282
- _resetNoProviderWarnForTest();
283
- }
284
- });
79
+ test("diagnostics distinguish installed from ready without reviving Scribe", () => {
80
+ expect(noProviderErrorFor(null)).toBe(NO_PROVIDER_ERROR);
81
+ expect(NO_PROVIDER_ERROR).toContain("transcription status");
82
+ expect(NO_PROVIDER_ERROR).toContain("standalone Scribe service is retired");
83
+ const message = noProviderErrorFor("whisper-cpp");
84
+ expect(message).toContain("binary is installed");
85
+ expect(message).toContain("not active and ready");
86
+ expect(message).toContain("transcription status");
87
+ expect(message).not.toContain("point SCRIBE_URL");
88
+ expect(message).not.toContain("resolves only a scribe URL");
285
89
  });
286
90
 
287
- /**
288
- * The `unavailable` error must be TRUE, not merely actionable.
289
- *
290
- * Found live: a box with `TRANSCRIPTION_PROVIDER=whisper-cpp`, `parakeet-cli`
291
- * on PATH, the model downloaded, and `[transcribe] worker started →
292
- * whisper-cpp` in the same boot log was writing "no transcription provider
293
- * configured — set TRANSCRIPTION_PROVIDER to a local provider" onto its
294
- * attachments. Every clause of that is false there, and the fix it prescribes
295
- * is a 400 MB re-download that changes nothing.
296
- *
297
- * The decision is deliberately unchanged — `unavailable` stays `unavailable`,
298
- * because this path resolves a scribe URL and nothing else. Only the sentence
299
- * moves.
300
- */
301
- describe("the unavailable message tells the truth about the local install", () => {
302
- const audio = "audio/webm";
303
- const noScribe = () => undefined;
304
-
305
- test("nothing runnable locally → the original message, unchanged", () => {
306
- // The fresh-install case the old string was written for, and it is still
307
- // exactly right there. Byte-identical so a fresh install sees no churn.
308
- const d = classifyAutoTranscribe(audio, {
309
- perVaultEnabled: true,
310
- getCachedScribeUrlImpl: noScribe,
311
- localProviderImpl: () => null,
312
- });
313
- expect(d.kind).toBe("unavailable");
314
- expect(noProviderErrorFor(d.kind === "unavailable" ? d.localProvider : null)).toBe(
315
- NO_PROVIDER_ERROR,
316
- );
317
- });
318
-
319
- test("a runnable local provider → a message that does not contradict the box", () => {
320
- const d = classifyAutoTranscribe(audio, {
321
- perVaultEnabled: true,
322
- getCachedScribeUrlImpl: noScribe,
323
- localProviderImpl: () => "whisper-cpp",
324
- });
325
- expect(d.kind).toBe("unavailable");
326
- const msg = noProviderErrorFor(d.kind === "unavailable" ? d.localProvider : null);
327
-
328
- // The three false claims the old string made on such a box.
329
- expect(msg).not.toContain("no transcription provider configured");
330
- expect(msg).not.toContain("set TRANSCRIPTION_PROVIDER to a local provider");
331
- expect(msg).not.toContain("transcription install");
332
- // And what it must say instead: the local install is fine, this path just
333
- // doesn't read it, and don't go reinstalling anything.
334
- expect(msg).toContain("whisper-cpp");
335
- expect(msg).toContain("SCRIBE_URL");
336
- expect(msg).toMatch(/does not consult it/);
337
- expect(msg).toMatch(/Reinstalling whisper-cpp will not change this/);
338
- });
339
-
340
- test("the decision itself is untouched — this diff moves no behaviour", () => {
341
- // Same inputs, both local-provider states: still `unavailable` either way.
342
- // If this ever diverges, the change stopped being diagnostic-only and
343
- // started deciding what gets transcribed.
344
- for (const local of [null, "whisper-cpp"]) {
345
- expect(
346
- classifyAutoTranscribe(audio, {
347
- perVaultEnabled: true,
348
- getCachedScribeUrlImpl: noScribe,
349
- localProviderImpl: () => local,
350
- }).kind,
351
- ).toBe("unavailable");
91
+ test("whisper-cpp real readiness requires model plus matching active worker, without Scribe", () => {
92
+ const keys = ["PARACHUTE_HOME", "WHISPER_CPP_BIN_DIR", "TRANSCRIPTION_PROVIDER", "TRANSCRIPTION_MODEL", "SCRIBE_URL"] as const;
93
+ const original = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
94
+ const home = mkdtempSync(join(tmpdir(), "auto-whisper-"));
95
+ const oldWorker = getTranscriptionWorker();
96
+ const oldProvider = getTranscriptionWorkerProvider();
97
+ try {
98
+ process.env.PARACHUTE_HOME = home;
99
+ process.env.WHISPER_CPP_BIN_DIR = join(home, "bin");
100
+ process.env.TRANSCRIPTION_PROVIDER = "whisper-cpp";
101
+ process.env.TRANSCRIPTION_MODEL = "whisper-base.en";
102
+ delete process.env.SCRIBE_URL;
103
+ mkdirSync(join(home, "bin"));
104
+ // Snapshot checks presence only; these are never executed.
105
+ writeFileSync(join(home, "bin", "whisper-cli"), "fixture");
106
+ writeFileSync(join(home, "bin", "ffmpeg"), "fixture");
107
+ const worker = { stop: async () => {}, tick: async () => 0, kick: async () => {} };
108
+ setTranscriptionWorker(worker, "whisper-cpp");
109
+ expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("unavailable");
110
+ const models = join(home, "transcription", "models");
111
+ mkdirSync(models, { recursive: true });
112
+ writeFileSync(join(models, findModel("whisper-base.en")!.filename), "fixture");
113
+ expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("transcribe");
114
+ setTranscriptionWorker(null);
115
+ expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("unavailable");
116
+ } finally {
117
+ setTranscriptionWorker(oldWorker, oldProvider);
118
+ for (const key of keys) {
119
+ if (original[key] === undefined) delete process.env[key]; else process.env[key] = original[key];
352
120
  }
353
- // And a reachable scribe still wins outright, local install or not.
354
- expect(
355
- classifyAutoTranscribe(audio, {
356
- perVaultEnabled: true,
357
- getCachedScribeUrlImpl: () => "http://127.0.0.1:1943",
358
- localProviderImpl: () => "whisper-cpp",
359
- }).kind,
360
- ).toBe("transcribe");
361
- });
362
-
363
- test("an installed provider with ffmpeg missing STILL gets the honest message", () => {
364
- // The trap that cost a rewrite: `snap.ready` also demands ffmpeg, so it
365
- // varies box to box. A machine with the provider installed and ffmpeg
366
- // missing is still one where "no transcription provider configured" is
367
- // false, so the discriminator is INSTALLED, not READY — it answers the
368
- // question the sentence asks, identically everywhere.
369
- const msg = noProviderErrorFor("whisper-cpp");
370
- expect(msg).not.toBe(NO_PROVIDER_ERROR);
371
- // And it must not over-claim in the other direction: with ffmpeg missing
372
- // the install is present but not necessarily working, so the message says
373
- // requests are ROUTED to it and points at status for the real state.
374
- expect(msg).toMatch(/routed to it/);
375
- expect(msg).toContain("transcription status");
376
- expect(msg).not.toMatch(/is fine/);
377
- });
121
+ rmSync(home, { recursive: true, force: true });
122
+ }
123
+ });
378
124
 
379
- test("the provider NAME is not the discriminator — the fresh-install trap", () => {
380
- // `resolveTranscriptionProviderName` returns "whisper-cpp" on a box where
381
- // nothing is set up at all, because that is the default when no scribe URL
382
- // resolves. Keying off the name would tell a fresh-install operator their
383
- // whisper-cpp install is fine when they have never installed one. The
384
- // discriminator is RUNNABILITY, and null must produce the old message.
385
- expect(noProviderErrorFor(null)).toBe(NO_PROVIDER_ERROR);
386
- expect(noProviderErrorFor("whisper-cpp")).not.toBe(NO_PROVIDER_ERROR);
387
- });
125
+ test("missing-provider warning is throttled and resets", () => {
126
+ const previous = console.warn;
127
+ const messages: unknown[] = [];
128
+ console.warn = (...args) => { messages.push(args); };
129
+ try {
130
+ _resetNoProviderWarnForTest();
131
+ warnNoTranscriptionProvider(() => 60_000);
132
+ warnNoTranscriptionProvider(() => 60_001);
133
+ expect(messages).toHaveLength(1);
134
+ warnNoTranscriptionProvider(() => 120_000);
135
+ expect(messages).toHaveLength(2);
136
+ _resetNoProviderWarnForTest();
137
+ warnNoTranscriptionProvider(() => 120_001);
138
+ expect(messages).toHaveLength(3);
139
+ } finally { console.warn = previous; _resetNoProviderWarnForTest(); }
388
140
  });
@@ -1,247 +1,75 @@
1
- /**
2
- * Auto-transcribe trigger decision (vault#353, design 2026-05-21 Part 2).
3
- *
4
- * One pure function: given an attachment's mime-type + the operator's
5
- * settings + whether scribe is reachable, decide whether to enqueue the
6
- * attachment for the transcription worker. Lives in its own module so the
7
- * attachment-write code path (`routes.ts`) and the retry endpoint share the
8
- * same gate without duplicating logic.
9
- */
10
-
1
+ /** Automatic uploads follow the selected, wired transcription worker (#751). */
11
2
  import { readGlobalConfig } from "./config.ts";
12
- import { getCachedScribeUrl } from "./scribe-discovery.ts";
13
3
  import { buildTranscriptionSnapshot } from "./transcription-routes.ts";
4
+ import { getTranscriptionWorkerProvider } from "./transcription-registry.ts";
14
5
 
15
- /**
16
- * The name of a local provider that is INSTALLED on this box, or null.
17
- *
18
- * Delegates to the same snapshot `transcription status` and the admin SPA
19
- * answer from, so these surfaces share one implementation and cannot drift.
20
- *
21
- * Keyed on `binary.path`, deliberately, NOT on `snap.ready`. Readiness also
22
- * demands ffmpeg, so it varies box to box in a way this message shouldn't: a
23
- * machine with the provider installed but ffmpeg missing is still a machine
24
- * where "no transcription provider configured — set TRANSCRIPTION_PROVIDER to
25
- * a local provider" is false. `installed` answers the question the sentence
26
- * actually asks, and answers it the same way everywhere.
27
- * (Caught by running the real probe rather than the injected one — the unit
28
- * tests inject `localProviderImpl` and would never have shown the difference.)
29
- *
30
- * Errors swallow to null: a broken probe must degrade to the old, flat message,
31
- * never take down an attachment upload.
32
- */
33
- function installedLocalProvider(): string | null {
6
+ type ProviderState = { ready: boolean; localProvider: string | null };
7
+ function providerState(): ProviderState {
34
8
  try {
35
- const snap = buildTranscriptionSnapshot();
36
- if (snap.provider === "scribe-http") return null;
37
- return snap.binary.path ? snap.provider : null;
9
+ const snapshot = buildTranscriptionSnapshot();
10
+ return {
11
+ ready: snapshot.active && snapshot.ready &&
12
+ getTranscriptionWorkerProvider() === snapshot.provider,
13
+ // The snapshot's binary field describes whisper.cpp tools, not every
14
+ // provider. Do not use it to claim a Python/transcribe-cpp install exists.
15
+ localProvider: snapshot.provider === "whisper-cpp" && snapshot.binary.path
16
+ ? snapshot.provider : null,
17
+ };
38
18
  } catch {
39
- return null;
19
+ // A failed readiness probe must not break attachment upload.
20
+ return { ready: false, localProvider: null };
40
21
  }
41
22
  }
42
-
43
- /**
44
- * Pre-vault#353 callers passed `transcribe: true` explicitly on the
45
- * attachment POST. The auto-transcribe path inlines the decision: if the
46
- * upload is an audio mime-type AND the toggle is on AND scribe is reachable,
47
- * the worker is enqueued. This function is the single decision site.
48
- *
49
- * Returns `true` only when ALL three conditions hold:
50
- * 1. mime-type starts with `audio/` (case-insensitive).
51
- * 2. The resolved auto-transcribe toggle is not `false`. Resolution is
52
- * **per-vault → global → true**:
53
- * - `perVaultEnabled` (the owning vault's own `auto_transcribe.enabled`)
54
- * wins when set — this is what makes scribe's "link to vault X" affect
55
- * only X, not the whole server.
56
- * - else the server-wide `globalConfig.auto_transcribe?.enabled`.
57
- * - else `true` (default ON — once scribe is reachable, audio
58
- * transcribes without a separate config step). Operators who want it
59
- * OFF set `auto_transcribe.enabled: false` explicitly (per-vault or
60
- * globally).
61
- * `enabledOverride`, when present, hard-overrides the whole chain (used
62
- * by the explicit caller-opt-in path).
63
- * 3. Scribe is discoverable (services.json entry OR SCRIBE_URL env).
64
- *
65
- * The three conditions are independent guards: a single `false` is sufficient
66
- * to skip enqueuing. The audio stays as a regular attachment in that case.
67
- */
68
- export function shouldAutoTranscribe(
69
- mimeType: string,
70
- opts: {
71
- /** Injection seam for tests — defaults to live globals. */
72
- readGlobalConfigImpl?: typeof readGlobalConfig;
73
- getCachedScribeUrlImpl?: () => string | undefined;
74
- /**
75
- * The owning vault's per-vault `auto_transcribe.enabled` (vault.yaml).
76
- * Takes precedence over the global toggle when set, so enabling/disabling
77
- * one vault doesn't move the rest. `undefined` (the vault left it unset)
78
- * falls through to the global toggle.
79
- */
80
- perVaultEnabled?: boolean;
81
- /**
82
- * Hard override of the entire per-vault→global→true chain. Used by the
83
- * explicit caller-opt-in path; not part of the normal precedence ladder.
84
- */
85
- enabledOverride?: boolean;
86
- } = {},
87
- ): boolean {
88
- if (typeof mimeType !== "string" || !mimeType.toLowerCase().startsWith("audio/")) {
89
- return false;
90
- }
91
- return classifyAutoTranscribe(mimeType, opts).kind === "transcribe";
92
- }
93
-
94
- /**
95
- * Why an audio attachment is (or isn't) being transcribed. The three outcomes
96
- * are NOT interchangeable, which is the whole point of this type:
97
- *
98
- * - `transcribe` — enqueue it.
99
- * - `disabled` — the operator turned auto-transcribe off. Silence is
100
- * correct: they asked for nothing to happen.
101
- * - `unavailable` — auto-transcribe is ON, but no provider is reachable.
102
- * This is a MISCONFIGURATION, and silence is wrong.
103
- *
104
- * vault#643: `shouldAutoTranscribe` collapsed the last two into `false`, so a
105
- * box with transcription enabled and no reachable provider accepted audio,
106
- * transcribed nothing, wrote no marker, logged nothing, and left an attachment
107
- * indistinguishable from a plain upload. Observed on a fresh install: the
108
- * provider resolves to `scribe-http` by default, nothing sets `SCRIBE_URL`, and
109
- * voice memos silently never transcribe.
110
- *
111
- * The hosted door already gets this right — `workers/vault/src/vault-do.ts`
112
- * marks a terminal state ("voice not enabled for this plan", "monthly voice
113
- * limit reached") rather than skipping quietly, precisely so the operator never
114
- * faces an eternal spinner. This brings self-host to the same posture.
115
- */
23
+ type AutoTranscribeOptions = {
24
+ readGlobalConfigImpl?: typeof readGlobalConfig;
25
+ /** Test seam; production uses the registered worker and shared status probe. */
26
+ providerStateImpl?: () => ProviderState;
27
+ /** Per-vault → global → true; explicit caller overrides still win. */
28
+ perVaultEnabled?: boolean;
29
+ enabledOverride?: boolean;
30
+ };
116
31
  export type AutoTranscribeDecision =
117
32
  | { kind: "transcribe" }
118
33
  | { kind: "not-audio" }
119
34
  | { kind: "disabled" }
120
- /**
121
- * `localProvider` names a local provider INSTALLED on this box (its binary
122
- * resolves), or null when there is nothing local here.
123
- *
124
- * Installation, deliberately, and not the configured provider NAME. The name
125
- * is a trap: `resolveTranscriptionProviderName` returns `whisper-cpp` on a
126
- * box where nothing at all is set up, because that is the default when no
127
- * scribe URL resolves. Keying the message off the name would tell a
128
- * fresh-install operator their whisper-cpp install is present when they have
129
- * never installed one — trading one false sentence for another.
130
- *
131
- * It does NOT change the decision: `unavailable` is `unavailable` either way,
132
- * because this path resolves a scribe URL and nothing else. It exists purely
133
- * so the error can be true. See `noProviderErrorFor`.
134
- */
135
35
  | { kind: "unavailable"; localProvider: string | null };
136
36
 
137
- /** The full decision behind `shouldAutoTranscribe`. Same inputs, more answer. */
138
37
  export function classifyAutoTranscribe(
139
38
  mimeType: string,
140
- opts: {
141
- readGlobalConfigImpl?: typeof readGlobalConfig;
142
- getCachedScribeUrlImpl?: () => string | undefined;
143
- perVaultEnabled?: boolean;
144
- enabledOverride?: boolean;
145
- /**
146
- * Injectable, same shape as the two impls above. Returns the name of a
147
- * local provider installed here, or null. Defaults to the shared
148
- * transcription snapshot.
149
- */
150
- localProviderImpl?: () => string | null;
151
- } = {},
39
+ opts: AutoTranscribeOptions = {},
152
40
  ): AutoTranscribeDecision {
153
41
  if (typeof mimeType !== "string" || !mimeType.toLowerCase().startsWith("audio/")) {
154
42
  return { kind: "not-audio" };
155
43
  }
156
- const enabled = opts.enabledOverride
157
- ?? opts.perVaultEnabled
158
- ?? (opts.readGlobalConfigImpl ?? readGlobalConfig)().auto_transcribe?.enabled
159
- ?? true;
44
+ const enabled = opts.enabledOverride ?? opts.perVaultEnabled ??
45
+ (opts.readGlobalConfigImpl ?? readGlobalConfig)().auto_transcribe?.enabled ?? true;
160
46
  if (!enabled) return { kind: "disabled" };
161
- const url = (opts.getCachedScribeUrlImpl ?? getCachedScribeUrl)();
162
- if (!url || !url.trim()) {
163
- // Still unavailable — this path reads a scribe URL and nothing else, and
164
- // that is deliberately unchanged here. We only ask whether something local
165
- // would run, so the message can name the real situation instead of telling
166
- // an operator to do what they have already done.
167
- return {
168
- kind: "unavailable",
169
- localProvider: (opts.localProviderImpl ?? installedLocalProvider)(),
170
- };
171
- }
172
- return { kind: "transcribe" };
47
+ const state = (opts.providerStateImpl ?? providerState)();
48
+ return state.ready
49
+ ? { kind: "transcribe" }
50
+ : { kind: "unavailable", localProvider: state.localProvider };
51
+ }
52
+ export function shouldAutoTranscribe(mimeType: string, opts: AutoTranscribeOptions = {}): boolean {
53
+ return classifyAutoTranscribe(mimeType, opts).kind === "transcribe";
173
54
  }
174
-
175
- /**
176
- * The `transcribe_error` written when auto-transcribe is on but no provider is
177
- * reachable AND nothing local is configured. Deliberately actionable — the
178
- * operator needs to know which of the two things to do, not just that something
179
- * went wrong.
180
- *
181
- * Correct only when the resolved provider is `scribe-http`. On a box that HAS a
182
- * local provider this sentence is false in the most expensive way — see
183
- * `noProviderErrorFor`.
184
- */
185
55
  export const NO_PROVIDER_ERROR =
186
- "no transcription provider configured — set TRANSCRIPTION_PROVIDER to a local " +
187
- "provider (see `parachute-vault transcription install`), or point SCRIBE_URL at " +
188
- "a transcription service";
189
-
190
- /**
191
- * The `transcribe_error` for an `unavailable` decision, told truthfully.
192
- *
193
- * Found live: a box with `TRANSCRIPTION_PROVIDER=whisper-cpp`, `parakeet-cli` on
194
- * PATH, the model downloaded, and `[transcribe] worker started → whisper-cpp` in
195
- * the same boot was writing `NO_PROVIDER_ERROR` onto its attachments — telling
196
- * the operator to set the variable they had set and install the provider that
197
- * was already running. Same failure mode as the `transcription status` bug
198
- * (#643): a diagnostic that lies during the exact task it exists for, and this
199
- * one sends someone to re-download a 400 MB model to fix nothing.
200
- *
201
- * The asymmetry is real and worth naming in the message rather than hiding:
202
- * `server.ts` starts a local worker with no scribe URL anywhere in the branch,
203
- * but this path resolves ONLY a scribe URL. So a local provider genuinely does
204
- * serve explicit `transcribe: true` requests while genuinely being invisible
205
- * here. An operator who is not told that will reasonably conclude their install
206
- * is broken.
207
- *
208
- * Deliberately NOT a fix for the asymmetry itself — teaching this path to see
209
- * local providers changes what gets transcribed, which is a behaviour question
210
- * and not this diff's to answer.
211
- */
56
+ "no transcription provider configured or ready — check `parachute-vault transcription status` " +
57
+ "and TRANSCRIPTION_PROVIDER. For local transcription use `parachute-vault transcription install`. " +
58
+ "For an explicitly selected legacy remote provider, check its SCRIBE_URL and worker status. " +
59
+ "The standalone Scribe service is retired.";
212
60
  export function noProviderErrorFor(localProvider: string | null): string {
213
61
  if (!localProvider) return NO_PROVIDER_ERROR;
214
- return (
215
- `auto-transcribe found no reachable provider: it resolves only a scribe URL ` +
216
- `(SCRIBE_URL, or a \`parachute-scribe\` entry in services.json), and neither is set. ` +
217
- `A local ${localProvider} install IS present here and explicit transcription requests ` +
218
- `are routed to it — this path does not consult it. Reinstalling ${localProvider} will ` +
219
- `not change this. To auto-transcribe uploads, point SCRIBE_URL at a transcription ` +
220
- `service; otherwise request transcription explicitly on the attachment. ` +
221
- `\`parachute-vault transcription status\` reports the local install's own state.`
222
- );
62
+ return `auto-transcribe is unavailable: a local ${localProvider} binary is installed, ` +
63
+ "but the selected worker is not active and ready. Check `parachute-vault transcription status` " +
64
+ "for missing model/runtime dependencies or a required server restart. " +
65
+ "A binary alone does not establish readiness; reinstalling it is not necessarily the fix.";
223
66
  }
224
-
225
- /** Throttle for {@link warnNoTranscriptionProvider}. */
226
67
  const NO_PROVIDER_WARN_INTERVAL_MS = 60_000;
227
68
  let lastNoProviderWarnAt = 0;
228
-
229
- /**
230
- * Warn (at most once a minute) that audio is arriving with nowhere to send it.
231
- *
232
- * Throttled because it fires per-upload: a bulk import of a hundred voice
233
- * memos should produce one actionable line, not a hundred. Silence was the old
234
- * behaviour and it is what made this invisible — a box can accept audio for
235
- * months and never say that transcription isn't wired up.
236
- */
237
69
  export function warnNoTranscriptionProvider(now: () => number = Date.now): void {
238
70
  const t = now();
239
71
  if (t - lastNoProviderWarnAt < NO_PROVIDER_WARN_INTERVAL_MS) return;
240
72
  lastNoProviderWarnAt = t;
241
- console.warn(`[transcribe] audio attachment accepted but ${NO_PROVIDER_ERROR}.`);
242
- }
243
-
244
- /** Test seam: forget the throttle window. */
245
- export function _resetNoProviderWarnForTest(): void {
246
- lastNoProviderWarnAt = 0;
73
+ console.warn(`[transcribe] audio attachment accepted but ${NO_PROVIDER_ERROR}`);
247
74
  }
75
+ export function _resetNoProviderWarnForTest(): void { lastNoProviderWarnAt = 0; }
package/src/server.ts CHANGED
@@ -145,7 +145,7 @@ function wireTranscriptionWorker(worker: TranscriptionWorker): void {
145
145
  worker,
146
146
  (store) => getVaultNameForStore(store as never),
147
147
  );
148
- setTranscriptionWorker(worker);
148
+ setTranscriptionWorker(worker, providerName);
149
149
  }
150
150
 
151
151
  // Fields the worker needs regardless of provider (queue/retention/context).
@@ -157,8 +157,8 @@ const commonWorkerOpts = {
157
157
  getContextPredicates: (vault: string) => readVaultConfig(vault)?.transcription?.context,
158
158
  };
159
159
 
160
- // Provider selection (scribe-fold Phase 2a). Default is `scribe-http` — unset
161
- // TRANSCRIPTION_PROVIDER means the existing scribe-http path runs unchanged.
160
+ // Provider selection uses the shared resolver (local whisper-cpp by default
161
+ // without legacy remote discovery). Bind this identity when wiring the worker.
162
162
  const providerName = resolveTranscriptionProviderName();
163
163
  if (providerName === "whisper-cpp") {
164
164
  // whisper.cpp's prebuilt CLIs — the local, no-Python path that actually
@@ -289,9 +289,10 @@ if (providerName === "whisper-cpp") {
289
289
  console.warn(
290
290
  "[transcribe] NO transcription provider is reachable — audio attachments " +
291
291
  "will be accepted but never transcribed. " +
292
- `Provider resolved to "${providerName}". Fix with either: ` +
293
- "`parachute-vault transcription install` + TRANSCRIPTION_PROVIDER=<local provider>, " +
294
- "or point SCRIBE_URL at a transcription service. " +
292
+ `Provider resolved to "${providerName}". The standalone Scribe service is retired. ` +
293
+ "For explicit transcription, use `parachute-vault transcription install` and " +
294
+ "check `parachute-vault transcription status`. Automatic uploads require " +
295
+ "the selected worker to be active and ready. " +
295
296
  "Set `auto_transcribe.enabled: false` to silence this if you don't want transcription.",
296
297
  );
297
298
  }
@@ -756,4 +757,3 @@ setInterval(() => {
756
757
  }
757
758
  void shutdown("STOP_SIGNAL");
758
759
  }, STOP_POLL_MS).unref();
759
-
@@ -1,22 +1,24 @@
1
1
  /**
2
2
  * Process-singleton holder for the active TranscriptionWorker (vault#353).
3
3
  *
4
- * Mirrors `mirror-registry.ts`: `server.ts` constructs the worker on boot
5
- * (when scribe is discoverable), and the REST retry endpoint
6
- * (`/api/notes/:id/retry-transcription`) picks it up here to call `kick()`
7
- * for an event-driven re-run. Absent the worker (no scribe), the retry
8
- * endpoint still flips the attachment back to `pending` so the sweep would
9
- * pick it up — but it'll just sit there until scribe shows up.
4
+ * The server registers its selected local or remote worker at boot after
5
+ * wiring the attachment hook. Retry endpoints use the worker to kick jobs;
6
+ * automatic-upload eligibility also checks the startup provider identity.
10
7
  */
11
8
 
12
9
  import type { TranscriptionWorker } from "./transcription-worker.ts";
13
10
 
14
11
  let activeWorker: TranscriptionWorker | null = null;
12
+ let activeProvider: string | null = null;
15
13
 
16
- export function setTranscriptionWorker(worker: TranscriptionWorker | null): void {
14
+ export function setTranscriptionWorker(worker: TranscriptionWorker | null, provider: string | null = null): void {
17
15
  activeWorker = worker;
16
+ activeProvider = worker ? provider : null;
18
17
  }
19
18
 
19
+ /** Provider bound at worker startup, not a newly edited environment value. */
20
+ export function getTranscriptionWorkerProvider(): string | null { return activeProvider; }
21
+
20
22
  export function getTranscriptionWorker(): TranscriptionWorker | null {
21
23
  return activeWorker;
22
24
  }
package/src/vault.test.ts CHANGED
@@ -17,6 +17,7 @@ import type { TagScopeCtx } from "./routes.ts";
17
17
  import { extractApiKey } from "./auth.ts";
18
18
  import { startTranscriptionWorker } from "./transcription-worker.ts";
19
19
  import { setTranscriptionWorker } from "./transcription-registry.ts";
20
+ import { clearScribeUrlCache } from "./scribe-discovery.ts";
20
21
  import type { Store } from "../core/src/types.ts";
21
22
 
22
23
  let db: Database;
@@ -3314,23 +3315,45 @@ describe("HTTP /notes", async () => {
3314
3315
  // left looking like an ordinary upload — it records WHY nothing happened.
3315
3316
  // It is still not `pending` (nothing was enqueued) and the note is still
3316
3317
  // untouched (no stub, since the caller never asked for one).
3317
- test("audio with no flag + no provider records the failure; note untouched", async () => {
3318
- await store.createNote("note body", { id: "v2" });
3319
- const res = await handleNotes(
3320
- mkReq("POST", "/notes/v2/attachments", {
3321
- path: "memos/memo-2.webm",
3322
- mimeType: "audio/webm",
3323
- }),
3324
- store,
3325
- "/v2/attachments",
3326
- );
3327
- expect(res.status).toBe(201);
3328
- const att = await res.json() as any;
3329
- expect(att.metadata?.transcribe_status).toBe("failed");
3330
- expect(att.metadata?.transcribe_error).toMatch(/no transcription provider configured/i);
3331
-
3332
- const note = await store.getNote("v2");
3333
- expect((note!.metadata as any)?.transcribe_stub).toBeUndefined();
3318
+ //
3319
+ // Fixture: select the remote provider so the presence/absence of a local
3320
+ // whisper-cpp install on the host cannot change the message. With no
3321
+ // SCRIBE_URL configured, auto-transcribe is still unavailable and reports
3322
+ // the same actionable no-provider error on every box.
3323
+ test("audio with no flag + scribe-http without URL records the failure; note untouched", async () => {
3324
+ const prevProvider = process.env.TRANSCRIPTION_PROVIDER;
3325
+ const prevScribeUrl = process.env.SCRIBE_URL;
3326
+ process.env.TRANSCRIPTION_PROVIDER = "scribe-http";
3327
+ delete process.env.SCRIBE_URL;
3328
+ clearScribeUrlCache();
3329
+ try {
3330
+ await store.createNote("note body", { id: "v2" });
3331
+ const res = await handleNotes(
3332
+ mkReq("POST", "/notes/v2/attachments", {
3333
+ path: "memos/memo-2.webm",
3334
+ mimeType: "audio/webm",
3335
+ }),
3336
+ store,
3337
+ "/v2/attachments",
3338
+ );
3339
+ expect(res.status).toBe(201);
3340
+ const att = await res.json() as any;
3341
+ expect(att.metadata?.transcribe_status).toBe("failed");
3342
+ expect(att.metadata?.transcribe_error).toMatch(/no transcription provider configured/i);
3343
+ expect(att.metadata?.transcribe_error).toMatch(/TRANSCRIPTION_PROVIDER/);
3344
+ expect(att.metadata?.transcribe_error).toMatch(/SCRIBE_URL/);
3345
+ expect(att.metadata?.transcribe_origin).toBe("auto");
3346
+
3347
+ const note = await store.getNote("v2");
3348
+ expect(note!.content).toBe("note body");
3349
+ expect((note!.metadata as any)?.transcribe_stub).toBeUndefined();
3350
+ } finally {
3351
+ if (prevProvider === undefined) delete process.env.TRANSCRIPTION_PROVIDER;
3352
+ else process.env.TRANSCRIPTION_PROVIDER = prevProvider;
3353
+ if (prevScribeUrl === undefined) delete process.env.SCRIBE_URL;
3354
+ else process.env.SCRIBE_URL = prevScribeUrl;
3355
+ clearScribeUrlCache();
3356
+ }
3334
3357
  });
3335
3358
 
3336
3359
  // The explicit opt-out. `transcribe: false` is a caller saying no, and it