@openparachute/vault 0.2.4 → 0.3.0
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/.claude/settings.local.json +2 -25
- package/CHANGELOG.md +64 -0
- package/CLAUDE.md +17 -7
- package/README.md +169 -136
- package/core/src/core.test.ts +591 -19
- package/core/src/hooks.ts +111 -3
- package/core/src/indexed-fields.test.ts +285 -0
- package/core/src/indexed-fields.ts +238 -0
- package/core/src/mcp.ts +127 -6
- package/core/src/notes.ts +153 -11
- package/core/src/query-operators.ts +174 -0
- package/core/src/schema.ts +69 -2
- package/core/src/store.ts +95 -1
- package/core/src/tag-schemas.ts +5 -0
- package/core/src/types.ts +28 -1
- package/docs/HTTP_API.md +105 -1
- package/docs/auth-model.md +340 -0
- package/package/package.json +32 -0
- package/package.json +2 -2
- package/src/auth.test.ts +83 -114
- package/src/auth.ts +68 -6
- package/src/backup-launchd.ts +1 -1
- package/src/backup.test.ts +1 -1
- package/src/backup.ts +18 -17
- package/src/bind.test.ts +28 -0
- package/src/bind.ts +19 -0
- package/src/cli.ts +228 -133
- package/src/config-triggers.test.ts +49 -0
- package/src/config.test.ts +317 -2
- package/src/config.ts +420 -40
- package/src/context.test.ts +136 -0
- package/src/context.ts +115 -0
- package/src/daemon.ts +17 -16
- package/src/doctor.test.ts +9 -7
- package/src/launchd.test.ts +1 -1
- package/src/launchd.ts +6 -6
- package/src/mcp-http.ts +75 -21
- package/src/mcp-install.test.ts +125 -0
- package/src/mcp-install.ts +60 -0
- package/src/mcp-tools.ts +34 -96
- package/src/module-config.ts +109 -0
- package/src/oauth.test.ts +345 -57
- package/src/oauth.ts +155 -35
- package/src/published.test.ts +2 -2
- package/src/routes.ts +209 -33
- package/src/routing.test.ts +817 -300
- package/src/routing.ts +204 -202
- package/src/scopes.test.ts +294 -0
- package/src/scopes.ts +253 -0
- package/src/scribe-env.test.ts +49 -0
- package/src/scribe-env.ts +33 -0
- package/src/server.ts +73 -9
- package/src/services-manifest.test.ts +140 -0
- package/src/services-manifest.ts +99 -0
- package/src/systemd.ts +3 -3
- package/src/token-store.ts +42 -9
- package/src/transcription-worker.test.ts +864 -0
- package/src/transcription-worker.ts +501 -0
- package/src/triggers.test.ts +191 -1
- package/src/triggers.ts +17 -2
- package/src/vault.test.ts +693 -77
- package/src/version.test.ts +1 -1
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/web/README.md +0 -73
- package/web/bun.lock +0 -827
- package/web/eslint.config.js +0 -23
- package/web/index.html +0 -15
- package/web/package.json +0 -36
- package/web/public/favicon.svg +0 -1
- package/web/public/icons.svg +0 -24
- package/web/src/App.tsx +0 -149
- package/web/src/Graph.tsx +0 -200
- package/web/src/NoteView.tsx +0 -155
- package/web/src/Sidebar.tsx +0 -186
- package/web/src/api.ts +0 -21
- package/web/src/index.css +0 -50
- package/web/src/main.tsx +0 -10
- package/web/src/types.ts +0 -37
- package/web/src/utils.ts +0 -107
- package/web/tsconfig.app.json +0 -25
- package/web/tsconfig.json +0 -7
- package/web/tsconfig.node.json +0 -24
- package/web/vite.config.ts +0 -16
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { mkdirSync, rmSync, writeFileSync, existsSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { tmpdir } from "os";
|
|
6
|
+
import { BunStore } from "./vault-store.ts";
|
|
7
|
+
import { startTranscriptionWorker, registerTranscriptionHook } from "./transcription-worker.ts";
|
|
8
|
+
import { HookRegistry } from "../core/src/hooks.ts";
|
|
9
|
+
import { SqliteStore } from "../core/src/store.ts";
|
|
10
|
+
import type { Store } from "../core/src/types.ts";
|
|
11
|
+
|
|
12
|
+
let db: Database;
|
|
13
|
+
let store: BunStore;
|
|
14
|
+
let tmpDir: string;
|
|
15
|
+
let assetsRoot: string;
|
|
16
|
+
|
|
17
|
+
const silentLogger = { error: () => {}, info: () => {} };
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
tmpDir = join(tmpdir(), `transcribe-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
21
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
22
|
+
assetsRoot = join(tmpDir, "assets");
|
|
23
|
+
mkdirSync(assetsRoot, { recursive: true });
|
|
24
|
+
db = new Database(join(tmpDir, "test.db"));
|
|
25
|
+
store = new BunStore(db);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
db.close();
|
|
30
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function mkFetchMock(responses: Array<{ text: string } | { error: string; status?: number }>): typeof fetch {
|
|
34
|
+
let i = 0;
|
|
35
|
+
return (async (_url: RequestInfo | URL, _init?: RequestInit) => {
|
|
36
|
+
const r = responses[Math.min(i, responses.length - 1)];
|
|
37
|
+
i++;
|
|
38
|
+
if ("error" in r) {
|
|
39
|
+
return new Response(r.error, { status: r.status ?? 500 });
|
|
40
|
+
}
|
|
41
|
+
return new Response(JSON.stringify({ text: r.text }), {
|
|
42
|
+
status: 200,
|
|
43
|
+
headers: { "content-type": "application/json" },
|
|
44
|
+
});
|
|
45
|
+
}) as typeof fetch;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function seedAudio(relPath: string): string {
|
|
49
|
+
const full = join(assetsRoot, relPath);
|
|
50
|
+
mkdirSync(join(full, "..").toString(), { recursive: true });
|
|
51
|
+
writeFileSync(full, Buffer.from([1, 2, 3, 4]));
|
|
52
|
+
return full;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeWorker(opts: {
|
|
56
|
+
fetchImpl: typeof fetch;
|
|
57
|
+
retention?: "keep" | "until_transcribed" | "never";
|
|
58
|
+
maxAttempts?: number;
|
|
59
|
+
}) {
|
|
60
|
+
return startTranscriptionWorker({
|
|
61
|
+
vaultList: () => ["default"],
|
|
62
|
+
getStore: () => store as unknown as Store,
|
|
63
|
+
scribeUrl: "http://scribe.test",
|
|
64
|
+
resolveAssetsDir: () => assetsRoot,
|
|
65
|
+
getAudioRetention: () => opts.retention ?? "keep",
|
|
66
|
+
pollIntervalMs: 10_000_000, // never auto-fire; tests drive ticks manually
|
|
67
|
+
maxAttempts: opts.maxAttempts ?? 3,
|
|
68
|
+
fetchImpl: opts.fetchImpl,
|
|
69
|
+
logger: silentLogger,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("transcription worker", () => {
|
|
74
|
+
test("happy path: replaces _Transcript pending._ and clears stub marker", async () => {
|
|
75
|
+
const note = await store.createNote(
|
|
76
|
+
"# 🎙️ Voice memo\n\n_Transcript pending._\n",
|
|
77
|
+
{ id: "n1", metadata: { transcribe_stub: true } },
|
|
78
|
+
);
|
|
79
|
+
seedAudio("memos/a.webm");
|
|
80
|
+
await store.addAttachment(note.id, "memos/a.webm", "audio/webm", {
|
|
81
|
+
transcribe_status: "pending",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const worker = makeWorker({
|
|
85
|
+
fetchImpl: mkFetchMock([{ text: "hello world transcript" }]),
|
|
86
|
+
});
|
|
87
|
+
try {
|
|
88
|
+
const processed = await worker.tick();
|
|
89
|
+
expect(processed).toBe(1);
|
|
90
|
+
} finally {
|
|
91
|
+
await worker.stop();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const updated = await store.getNote("n1");
|
|
95
|
+
expect(updated!.content).toBe("# 🎙️ Voice memo\n\nhello world transcript\n");
|
|
96
|
+
expect((updated!.metadata as any)?.transcribe_stub).toBeUndefined();
|
|
97
|
+
|
|
98
|
+
const [att] = await store.getAttachments("n1");
|
|
99
|
+
expect(att.metadata?.transcribe_status).toBe("done");
|
|
100
|
+
expect(att.metadata?.transcript).toBe("hello world transcript");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("no-clobber: stub flag absent → does not touch note content", async () => {
|
|
104
|
+
await store.createNote("my own edit", { id: "n2" });
|
|
105
|
+
seedAudio("memos/b.webm");
|
|
106
|
+
await store.addAttachment("n2", "memos/b.webm", "audio/webm", {
|
|
107
|
+
transcribe_status: "pending",
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const worker = makeWorker({
|
|
111
|
+
fetchImpl: mkFetchMock([{ text: "would clobber" }]),
|
|
112
|
+
});
|
|
113
|
+
try {
|
|
114
|
+
await worker.tick();
|
|
115
|
+
} finally {
|
|
116
|
+
await worker.stop();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const updated = await store.getNote("n2");
|
|
120
|
+
expect(updated!.content).toBe("my own edit");
|
|
121
|
+
const [att] = await store.getAttachments("n2");
|
|
122
|
+
// Transcript still captured on the attachment — we don't throw work away,
|
|
123
|
+
// we just don't overwrite the note the user explicitly edited.
|
|
124
|
+
expect(att.metadata?.transcribe_status).toBe("done");
|
|
125
|
+
expect(att.metadata?.transcript).toBe("would clobber");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("no placeholder: replaces full body when stub is set", async () => {
|
|
129
|
+
await store.createNote("", {
|
|
130
|
+
id: "n3",
|
|
131
|
+
metadata: { transcribe_stub: true },
|
|
132
|
+
});
|
|
133
|
+
seedAudio("memos/c.webm");
|
|
134
|
+
await store.addAttachment("n3", "memos/c.webm", "audio/webm", {
|
|
135
|
+
transcribe_status: "pending",
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const worker = makeWorker({
|
|
139
|
+
fetchImpl: mkFetchMock([{ text: "bare transcript" }]),
|
|
140
|
+
});
|
|
141
|
+
try {
|
|
142
|
+
await worker.tick();
|
|
143
|
+
} finally {
|
|
144
|
+
await worker.stop();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const updated = await store.getNote("n3");
|
|
148
|
+
expect(updated!.content).toBe("bare transcript");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("retry on failure: status stays pending with backoff + attempts bumped", async () => {
|
|
152
|
+
await store.createNote("stub", {
|
|
153
|
+
id: "n4",
|
|
154
|
+
metadata: { transcribe_stub: true },
|
|
155
|
+
});
|
|
156
|
+
seedAudio("memos/d.webm");
|
|
157
|
+
await store.addAttachment("n4", "memos/d.webm", "audio/webm", {
|
|
158
|
+
transcribe_status: "pending",
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const worker = makeWorker({
|
|
162
|
+
fetchImpl: mkFetchMock([{ error: "scribe down", status: 503 }]),
|
|
163
|
+
maxAttempts: 3,
|
|
164
|
+
});
|
|
165
|
+
try {
|
|
166
|
+
await worker.tick();
|
|
167
|
+
} finally {
|
|
168
|
+
await worker.stop();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const [att] = await store.getAttachments("n4");
|
|
172
|
+
expect(att.metadata?.transcribe_status).toBe("pending");
|
|
173
|
+
expect(att.metadata?.transcribe_attempts).toBe(1);
|
|
174
|
+
expect(att.metadata?.transcribe_backoff_until).toBeTruthy();
|
|
175
|
+
expect(att.metadata?.transcribe_error).toContain("503");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("gives up after maxAttempts → status failed", async () => {
|
|
179
|
+
await store.createNote("stub", {
|
|
180
|
+
id: "n5",
|
|
181
|
+
metadata: { transcribe_stub: true },
|
|
182
|
+
});
|
|
183
|
+
seedAudio("memos/e.webm");
|
|
184
|
+
// Simulate already 2 attempts done — one more failure flips to failed
|
|
185
|
+
// when maxAttempts=3.
|
|
186
|
+
await store.addAttachment("n5", "memos/e.webm", "audio/webm", {
|
|
187
|
+
transcribe_status: "pending",
|
|
188
|
+
transcribe_attempts: 2,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const worker = makeWorker({
|
|
192
|
+
fetchImpl: mkFetchMock([{ error: "boom", status: 500 }]),
|
|
193
|
+
maxAttempts: 3,
|
|
194
|
+
});
|
|
195
|
+
try {
|
|
196
|
+
await worker.tick();
|
|
197
|
+
} finally {
|
|
198
|
+
await worker.stop();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const [att] = await store.getAttachments("n5");
|
|
202
|
+
expect(att.metadata?.transcribe_status).toBe("failed");
|
|
203
|
+
expect(att.metadata?.transcribe_attempts).toBe(3);
|
|
204
|
+
expect(att.metadata?.transcribe_error).toContain("boom");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("terminal failure with stub=true → note shows 'Transcription unavailable' and stub is cleared", async () => {
|
|
208
|
+
// Mirrors Lens's voice-memo stub shape: note with placeholder body and
|
|
209
|
+
// transcribe_stub marker, attachment pre-loaded near the retry limit.
|
|
210
|
+
await store.createNote(
|
|
211
|
+
"# 🎙️ Voice memo\n\n_Transcript pending._\n",
|
|
212
|
+
{ id: "unavail1", metadata: { transcribe_stub: true } },
|
|
213
|
+
);
|
|
214
|
+
seedAudio("memos/unavail1.webm");
|
|
215
|
+
await store.addAttachment("unavail1", "memos/unavail1.webm", "audio/webm", {
|
|
216
|
+
transcribe_status: "pending",
|
|
217
|
+
transcribe_attempts: 2,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const worker = makeWorker({
|
|
221
|
+
fetchImpl: mkFetchMock([{ error: "scribe down hard", status: 500 }]),
|
|
222
|
+
maxAttempts: 3,
|
|
223
|
+
});
|
|
224
|
+
try {
|
|
225
|
+
await worker.tick();
|
|
226
|
+
} finally {
|
|
227
|
+
await worker.stop();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const note = await store.getNote("unavail1");
|
|
231
|
+
expect(note!.content).toBe("# 🎙️ Voice memo\n\n_Transcription unavailable._\n");
|
|
232
|
+
expect((note!.metadata as any)?.transcribe_stub).toBeUndefined();
|
|
233
|
+
|
|
234
|
+
const [att] = await store.getAttachments("unavail1");
|
|
235
|
+
expect(att!.metadata?.transcribe_status).toBe("failed");
|
|
236
|
+
expect(att!.metadata?.transcribe_error).toContain("scribe down hard");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("audio-not-found with stub=true → note shows 'Transcription unavailable' and stub is cleared", async () => {
|
|
240
|
+
await store.createNote(
|
|
241
|
+
"# 🎙️ Voice memo\n\n_Transcript pending._\n",
|
|
242
|
+
{ id: "unavail2", metadata: { transcribe_stub: true } },
|
|
243
|
+
);
|
|
244
|
+
// No seedAudio — the file is deliberately missing.
|
|
245
|
+
await store.addAttachment("unavail2", "memos/gone.webm", "audio/webm", {
|
|
246
|
+
transcribe_status: "pending",
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
let called = 0;
|
|
250
|
+
const worker = makeWorker({
|
|
251
|
+
fetchImpl: (async () => {
|
|
252
|
+
called++;
|
|
253
|
+
return new Response("x", { status: 200 });
|
|
254
|
+
}) as typeof fetch,
|
|
255
|
+
});
|
|
256
|
+
try {
|
|
257
|
+
await worker.tick();
|
|
258
|
+
} finally {
|
|
259
|
+
await worker.stop();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Scribe was never called — audio-missing check short-circuits before
|
|
263
|
+
// the network call, same as before. What's new is the note rewrite.
|
|
264
|
+
expect(called).toBe(0);
|
|
265
|
+
|
|
266
|
+
const note = await store.getNote("unavail2");
|
|
267
|
+
expect(note!.content).toBe("# 🎙️ Voice memo\n\n_Transcription unavailable._\n");
|
|
268
|
+
expect((note!.metadata as any)?.transcribe_stub).toBeUndefined();
|
|
269
|
+
|
|
270
|
+
const [att] = await store.getAttachments("unavail2");
|
|
271
|
+
expect(att!.metadata?.transcribe_status).toBe("failed");
|
|
272
|
+
expect(att!.metadata?.transcribe_error).toContain("audio file not found");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("terminal failure with stub=false → note content is NOT touched", async () => {
|
|
276
|
+
// User edited the note after upload, which cleared the stub marker.
|
|
277
|
+
// Worker must not clobber their edit even though transcription failed.
|
|
278
|
+
await store.createNote("my own words", { id: "unavail3" });
|
|
279
|
+
seedAudio("memos/unavail3.webm");
|
|
280
|
+
await store.addAttachment("unavail3", "memos/unavail3.webm", "audio/webm", {
|
|
281
|
+
transcribe_status: "pending",
|
|
282
|
+
transcribe_attempts: 2,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const worker = makeWorker({
|
|
286
|
+
fetchImpl: mkFetchMock([{ error: "boom", status: 500 }]),
|
|
287
|
+
maxAttempts: 3,
|
|
288
|
+
});
|
|
289
|
+
try {
|
|
290
|
+
await worker.tick();
|
|
291
|
+
} finally {
|
|
292
|
+
await worker.stop();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const note = await store.getNote("unavail3");
|
|
296
|
+
expect(note!.content).toBe("my own words");
|
|
297
|
+
|
|
298
|
+
const [att] = await store.getAttachments("unavail3");
|
|
299
|
+
expect(att!.metadata?.transcribe_status).toBe("failed");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("FIFO: oldest pending is processed first", async () => {
|
|
303
|
+
await store.createNote("s", { id: "f1", metadata: { transcribe_stub: true } });
|
|
304
|
+
await store.createNote("s", { id: "f2", metadata: { transcribe_stub: true } });
|
|
305
|
+
seedAudio("memos/first.webm");
|
|
306
|
+
seedAudio("memos/second.webm");
|
|
307
|
+
await store.addAttachment("f1", "memos/first.webm", "audio/webm", {
|
|
308
|
+
transcribe_status: "pending",
|
|
309
|
+
});
|
|
310
|
+
// Ensure a distinct created_at — bun:sqlite stores ISO timestamps at ms
|
|
311
|
+
// granularity, so sleep briefly.
|
|
312
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
313
|
+
await store.addAttachment("f2", "memos/second.webm", "audio/webm", {
|
|
314
|
+
transcribe_status: "pending",
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const calls: string[] = [];
|
|
318
|
+
const worker = makeWorker({
|
|
319
|
+
fetchImpl: (async () => {
|
|
320
|
+
calls.push("call");
|
|
321
|
+
return new Response(JSON.stringify({ text: `t${calls.length}` }), {
|
|
322
|
+
status: 200,
|
|
323
|
+
headers: { "content-type": "application/json" },
|
|
324
|
+
});
|
|
325
|
+
}) as typeof fetch,
|
|
326
|
+
});
|
|
327
|
+
try {
|
|
328
|
+
await worker.tick();
|
|
329
|
+
} finally {
|
|
330
|
+
await worker.stop();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const n1 = await store.getNote("f1");
|
|
334
|
+
const n2 = await store.getNote("f2");
|
|
335
|
+
expect(n1!.content).toBe("t1");
|
|
336
|
+
expect(n2!.content).toBe("t2");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("backoff gate skips attachments whose backoff has not elapsed", async () => {
|
|
340
|
+
await store.createNote("s", { id: "b1", metadata: { transcribe_stub: true } });
|
|
341
|
+
seedAudio("memos/b1.webm");
|
|
342
|
+
const future = new Date(Date.now() + 60_000).toISOString();
|
|
343
|
+
await store.addAttachment("b1", "memos/b1.webm", "audio/webm", {
|
|
344
|
+
transcribe_status: "pending",
|
|
345
|
+
transcribe_attempts: 1,
|
|
346
|
+
transcribe_backoff_until: future,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
let called = 0;
|
|
350
|
+
const worker = makeWorker({
|
|
351
|
+
fetchImpl: (async () => {
|
|
352
|
+
called++;
|
|
353
|
+
return new Response(JSON.stringify({ text: "x" }), { status: 200 });
|
|
354
|
+
}) as typeof fetch,
|
|
355
|
+
});
|
|
356
|
+
try {
|
|
357
|
+
await worker.tick();
|
|
358
|
+
} finally {
|
|
359
|
+
await worker.stop();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
expect(called).toBe(0);
|
|
363
|
+
const [att] = await store.getAttachments("b1");
|
|
364
|
+
expect(att.metadata?.transcribe_status).toBe("pending");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("retention=until_transcribed unlinks the audio file after success", async () => {
|
|
368
|
+
await store.createNote("s", { id: "r1", metadata: { transcribe_stub: true } });
|
|
369
|
+
const full = seedAudio("memos/r1.webm");
|
|
370
|
+
await store.addAttachment("r1", "memos/r1.webm", "audio/webm", {
|
|
371
|
+
transcribe_status: "pending",
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const worker = makeWorker({
|
|
375
|
+
fetchImpl: mkFetchMock([{ text: "t" }]),
|
|
376
|
+
retention: "until_transcribed",
|
|
377
|
+
});
|
|
378
|
+
try {
|
|
379
|
+
await worker.tick();
|
|
380
|
+
} finally {
|
|
381
|
+
await worker.stop();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
expect(existsSync(full)).toBe(false);
|
|
385
|
+
// Attachment row preserved — transcript still addressable.
|
|
386
|
+
const [att] = await store.getAttachments("r1");
|
|
387
|
+
expect(att.metadata?.transcribe_status).toBe("done");
|
|
388
|
+
expect(att.metadata?.transcript).toBe("t");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("retention=never unlinks the audio file after success", async () => {
|
|
392
|
+
await store.createNote("s", { id: "rn1", metadata: { transcribe_stub: true } });
|
|
393
|
+
const full = seedAudio("memos/rn1.webm");
|
|
394
|
+
await store.addAttachment("rn1", "memos/rn1.webm", "audio/webm", {
|
|
395
|
+
transcribe_status: "pending",
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const worker = makeWorker({
|
|
399
|
+
fetchImpl: mkFetchMock([{ text: "t" }]),
|
|
400
|
+
retention: "never",
|
|
401
|
+
});
|
|
402
|
+
try {
|
|
403
|
+
await worker.tick();
|
|
404
|
+
} finally {
|
|
405
|
+
await worker.stop();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
expect(existsSync(full)).toBe(false);
|
|
409
|
+
const [att] = await store.getAttachments("rn1");
|
|
410
|
+
expect(att.metadata?.transcribe_status).toBe("done");
|
|
411
|
+
expect(att.metadata?.transcript).toBe("t");
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("retention=never unlinks the audio file after terminal failure", async () => {
|
|
415
|
+
await store.createNote("s", { id: "rn2", metadata: { transcribe_stub: true } });
|
|
416
|
+
const full = seedAudio("memos/rn2.webm");
|
|
417
|
+
// Pre-seed attempts=2 so a single tick with maxAttempts=3 is terminal.
|
|
418
|
+
await store.addAttachment("rn2", "memos/rn2.webm", "audio/webm", {
|
|
419
|
+
transcribe_status: "pending",
|
|
420
|
+
transcribe_attempts: 2,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const worker = makeWorker({
|
|
424
|
+
fetchImpl: mkFetchMock([{ error: "boom", status: 500 }]),
|
|
425
|
+
retention: "never",
|
|
426
|
+
maxAttempts: 3,
|
|
427
|
+
});
|
|
428
|
+
try {
|
|
429
|
+
await worker.tick();
|
|
430
|
+
} finally {
|
|
431
|
+
await worker.stop();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const [att] = await store.getAttachments("rn2");
|
|
435
|
+
expect(att.metadata?.transcribe_status).toBe("failed");
|
|
436
|
+
// The whole point of "never": audio gone even when transcription failed.
|
|
437
|
+
expect(existsSync(full)).toBe(false);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("retention=never keeps the audio file during non-terminal retry", async () => {
|
|
441
|
+
await store.createNote("s", { id: "rn3", metadata: { transcribe_stub: true } });
|
|
442
|
+
const full = seedAudio("memos/rn3.webm");
|
|
443
|
+
// attempts=0 so a single failure is retry-pending, not terminal.
|
|
444
|
+
await store.addAttachment("rn3", "memos/rn3.webm", "audio/webm", {
|
|
445
|
+
transcribe_status: "pending",
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
const worker = makeWorker({
|
|
449
|
+
fetchImpl: mkFetchMock([{ error: "transient", status: 503 }]),
|
|
450
|
+
retention: "never",
|
|
451
|
+
maxAttempts: 3,
|
|
452
|
+
});
|
|
453
|
+
try {
|
|
454
|
+
await worker.tick();
|
|
455
|
+
} finally {
|
|
456
|
+
await worker.stop();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const [att] = await store.getAttachments("rn3");
|
|
460
|
+
expect(att.metadata?.transcribe_status).toBe("pending");
|
|
461
|
+
// File must remain for the retry to have something to send.
|
|
462
|
+
expect(existsSync(full)).toBe(true);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("retention=keep leaves the audio file in place after success", async () => {
|
|
466
|
+
await store.createNote("s", { id: "k1", metadata: { transcribe_stub: true } });
|
|
467
|
+
const full = seedAudio("memos/k1.webm");
|
|
468
|
+
await store.addAttachment("k1", "memos/k1.webm", "audio/webm", {
|
|
469
|
+
transcribe_status: "pending",
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
const worker = makeWorker({
|
|
473
|
+
fetchImpl: mkFetchMock([{ text: "t" }]),
|
|
474
|
+
retention: "keep",
|
|
475
|
+
});
|
|
476
|
+
try {
|
|
477
|
+
await worker.tick();
|
|
478
|
+
} finally {
|
|
479
|
+
await worker.stop();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
expect(existsSync(full)).toBe(true);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("missing audio file → flips to failed, no infinite retry", async () => {
|
|
486
|
+
await store.createNote("s", { id: "m1", metadata: { transcribe_stub: true } });
|
|
487
|
+
await store.addAttachment("m1", "memos/not-there.webm", "audio/webm", {
|
|
488
|
+
transcribe_status: "pending",
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
let called = 0;
|
|
492
|
+
const worker = makeWorker({
|
|
493
|
+
fetchImpl: (async () => {
|
|
494
|
+
called++;
|
|
495
|
+
return new Response("x", { status: 200 });
|
|
496
|
+
}) as typeof fetch,
|
|
497
|
+
});
|
|
498
|
+
try {
|
|
499
|
+
await worker.tick();
|
|
500
|
+
} finally {
|
|
501
|
+
await worker.stop();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
expect(called).toBe(0);
|
|
505
|
+
const [att] = await store.getAttachments("m1");
|
|
506
|
+
expect(att.metadata?.transcribe_status).toBe("failed");
|
|
507
|
+
expect(att.metadata?.transcribe_error).toContain("audio file not found");
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
describe("transcription worker — auth + context", () => {
|
|
512
|
+
test("attaches multipart context part when getContextPredicates returns entries", async () => {
|
|
513
|
+
await store.createNote("stub", { id: "ctx1", metadata: { transcribe_stub: true } });
|
|
514
|
+
seedAudio("memos/ctx1.webm");
|
|
515
|
+
await store.addAttachment("ctx1", "memos/ctx1.webm", "audio/webm", {
|
|
516
|
+
transcribe_status: "pending",
|
|
517
|
+
});
|
|
518
|
+
// Seed a context note the worker will fetch via queryNotes.
|
|
519
|
+
await store.createNote("", {
|
|
520
|
+
id: "p1",
|
|
521
|
+
path: "People/Aaron.md",
|
|
522
|
+
tags: ["person"],
|
|
523
|
+
metadata: { summary: "founder", aliases: ["AG"] },
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
let captured: { headers: Headers; form: FormData } | null = null;
|
|
527
|
+
const fetchImpl = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
|
528
|
+
const form = init?.body as unknown as FormData;
|
|
529
|
+
captured = { headers: new Headers(init?.headers as HeadersInit), form };
|
|
530
|
+
return new Response(JSON.stringify({ text: "ok" }), {
|
|
531
|
+
status: 200,
|
|
532
|
+
headers: { "content-type": "application/json" },
|
|
533
|
+
});
|
|
534
|
+
}) as typeof fetch;
|
|
535
|
+
|
|
536
|
+
const worker = startTranscriptionWorker({
|
|
537
|
+
vaultList: () => ["default"],
|
|
538
|
+
getStore: () => store as unknown as Store,
|
|
539
|
+
scribeUrl: "http://scribe.test",
|
|
540
|
+
resolveAssetsDir: () => assetsRoot,
|
|
541
|
+
getContextPredicates: () => [
|
|
542
|
+
{ tag: "person", include_metadata: ["summary", "aliases"] },
|
|
543
|
+
],
|
|
544
|
+
pollIntervalMs: 10_000_000,
|
|
545
|
+
fetchImpl,
|
|
546
|
+
logger: silentLogger,
|
|
547
|
+
});
|
|
548
|
+
try {
|
|
549
|
+
await worker.tick();
|
|
550
|
+
} finally {
|
|
551
|
+
await worker.stop();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
expect(captured).not.toBeNull();
|
|
555
|
+
const part = captured!.form.get("context");
|
|
556
|
+
expect(part).toBeInstanceOf(Blob);
|
|
557
|
+
const body = JSON.parse(await (part as Blob).text());
|
|
558
|
+
expect(body.entries).toEqual([
|
|
559
|
+
{ name: "Aaron", summary: "founder", aliases: ["AG"] },
|
|
560
|
+
]);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("sends Bearer header when scribeToken is set", async () => {
|
|
564
|
+
await store.createNote("stub", { id: "auth1", metadata: { transcribe_stub: true } });
|
|
565
|
+
seedAudio("memos/auth1.webm");
|
|
566
|
+
await store.addAttachment("auth1", "memos/auth1.webm", "audio/webm", {
|
|
567
|
+
transcribe_status: "pending",
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
let capturedAuth: string | null = null;
|
|
571
|
+
const fetchImpl = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
|
572
|
+
capturedAuth = new Headers(init?.headers as HeadersInit).get("authorization");
|
|
573
|
+
return new Response(JSON.stringify({ text: "ok" }), { status: 200 });
|
|
574
|
+
}) as typeof fetch;
|
|
575
|
+
|
|
576
|
+
const worker = startTranscriptionWorker({
|
|
577
|
+
vaultList: () => ["default"],
|
|
578
|
+
getStore: () => store as unknown as Store,
|
|
579
|
+
scribeUrl: "http://scribe.test",
|
|
580
|
+
scribeToken: "shh-secret",
|
|
581
|
+
resolveAssetsDir: () => assetsRoot,
|
|
582
|
+
pollIntervalMs: 10_000_000,
|
|
583
|
+
fetchImpl,
|
|
584
|
+
logger: silentLogger,
|
|
585
|
+
});
|
|
586
|
+
try {
|
|
587
|
+
await worker.tick();
|
|
588
|
+
} finally {
|
|
589
|
+
await worker.stop();
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
expect(capturedAuth).toBe("Bearer shh-secret");
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test("omits Authorization header when scribeToken is unset (loopback back-compat)", async () => {
|
|
596
|
+
await store.createNote("stub", { id: "auth2", metadata: { transcribe_stub: true } });
|
|
597
|
+
seedAudio("memos/auth2.webm");
|
|
598
|
+
await store.addAttachment("auth2", "memos/auth2.webm", "audio/webm", {
|
|
599
|
+
transcribe_status: "pending",
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
let capturedAuth: string | null | undefined = undefined;
|
|
603
|
+
const fetchImpl = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
|
604
|
+
capturedAuth = new Headers(init?.headers as HeadersInit).get("authorization");
|
|
605
|
+
return new Response(JSON.stringify({ text: "ok" }), { status: 200 });
|
|
606
|
+
}) as typeof fetch;
|
|
607
|
+
|
|
608
|
+
const worker = startTranscriptionWorker({
|
|
609
|
+
vaultList: () => ["default"],
|
|
610
|
+
getStore: () => store as unknown as Store,
|
|
611
|
+
scribeUrl: "http://scribe.test",
|
|
612
|
+
resolveAssetsDir: () => assetsRoot,
|
|
613
|
+
pollIntervalMs: 10_000_000,
|
|
614
|
+
fetchImpl,
|
|
615
|
+
logger: silentLogger,
|
|
616
|
+
});
|
|
617
|
+
try {
|
|
618
|
+
await worker.tick();
|
|
619
|
+
} finally {
|
|
620
|
+
await worker.stop();
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Headers#get returns null when absent — this is how we confirm no header was set.
|
|
624
|
+
expect(capturedAuth).toBeNull();
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("no context attached when getContextPredicates is undefined (no regression)", async () => {
|
|
628
|
+
await store.createNote("stub", { id: "np1", metadata: { transcribe_stub: true } });
|
|
629
|
+
seedAudio("memos/np1.webm");
|
|
630
|
+
await store.addAttachment("np1", "memos/np1.webm", "audio/webm", {
|
|
631
|
+
transcribe_status: "pending",
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
let capturedForm: FormData | null = null;
|
|
635
|
+
const fetchImpl = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
|
636
|
+
capturedForm = init?.body as unknown as FormData;
|
|
637
|
+
return new Response(JSON.stringify({ text: "ok" }), { status: 200 });
|
|
638
|
+
}) as typeof fetch;
|
|
639
|
+
|
|
640
|
+
const worker = startTranscriptionWorker({
|
|
641
|
+
vaultList: () => ["default"],
|
|
642
|
+
getStore: () => store as unknown as Store,
|
|
643
|
+
scribeUrl: "http://scribe.test",
|
|
644
|
+
resolveAssetsDir: () => assetsRoot,
|
|
645
|
+
pollIntervalMs: 10_000_000,
|
|
646
|
+
fetchImpl,
|
|
647
|
+
logger: silentLogger,
|
|
648
|
+
});
|
|
649
|
+
try {
|
|
650
|
+
await worker.tick();
|
|
651
|
+
} finally {
|
|
652
|
+
await worker.stop();
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
expect(capturedForm).not.toBeNull();
|
|
656
|
+
expect(capturedForm!.get("context")).toBeNull();
|
|
657
|
+
expect(capturedForm!.get("file")).not.toBeNull();
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
describe("store.listAttachmentsByTranscribeStatus", () => {
|
|
662
|
+
test("returns only matching status, oldest first", async () => {
|
|
663
|
+
await store.createNote("s", { id: "q1" });
|
|
664
|
+
await store.addAttachment("q1", "a.webm", "audio/webm", { transcribe_status: "done" });
|
|
665
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
666
|
+
await store.addAttachment("q1", "b.webm", "audio/webm", { transcribe_status: "pending" });
|
|
667
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
668
|
+
await store.addAttachment("q1", "c.webm", "audio/webm", { transcribe_status: "pending" });
|
|
669
|
+
await store.addAttachment("q1", "d.webm", "audio/webm"); // no status
|
|
670
|
+
|
|
671
|
+
const pending = await store.listAttachmentsByTranscribeStatus("pending");
|
|
672
|
+
expect(pending).toHaveLength(2);
|
|
673
|
+
expect(pending[0]!.path).toBe("b.webm");
|
|
674
|
+
expect(pending[1]!.path).toBe("c.webm");
|
|
675
|
+
|
|
676
|
+
const done = await store.listAttachmentsByTranscribeStatus("done");
|
|
677
|
+
expect(done).toHaveLength(1);
|
|
678
|
+
expect(done[0]!.path).toBe("a.webm");
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
describe("transcription worker — hook-driven", () => {
|
|
683
|
+
// These tests use a private HookRegistry so they don't collide with
|
|
684
|
+
// defaultHookRegistry state or other test files.
|
|
685
|
+
let hooks: HookRegistry;
|
|
686
|
+
let hookedStore: SqliteStore;
|
|
687
|
+
let hookedDb: Database;
|
|
688
|
+
|
|
689
|
+
beforeEach(() => {
|
|
690
|
+
hookedDb = new Database(":memory:");
|
|
691
|
+
hooks = new HookRegistry({ concurrency: 4, logger: silentLogger });
|
|
692
|
+
hookedStore = new SqliteStore(hookedDb, { hooks });
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
afterEach(() => {
|
|
696
|
+
hookedDb.close();
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
test("attachment:created event triggers a cycle before the sweep fires", async () => {
|
|
700
|
+
await hookedStore.createNote("stub", { id: "h1", metadata: { transcribe_stub: true } });
|
|
701
|
+
seedAudio("memos/h1.webm");
|
|
702
|
+
|
|
703
|
+
let callCount = 0;
|
|
704
|
+
const fetchImpl = (async () => {
|
|
705
|
+
callCount++;
|
|
706
|
+
return new Response(JSON.stringify({ text: "hook-path" }), {
|
|
707
|
+
status: 200,
|
|
708
|
+
headers: { "content-type": "application/json" },
|
|
709
|
+
});
|
|
710
|
+
}) as unknown as typeof fetch;
|
|
711
|
+
|
|
712
|
+
const worker = startTranscriptionWorker({
|
|
713
|
+
vaultList: () => ["default"],
|
|
714
|
+
getStore: () => hookedStore as unknown as Store,
|
|
715
|
+
scribeUrl: "http://scribe.test",
|
|
716
|
+
resolveAssetsDir: () => assetsRoot,
|
|
717
|
+
// Sweep would never fire within the test window — we prove the hook
|
|
718
|
+
// path is what drives processing.
|
|
719
|
+
pollIntervalMs: 10_000_000,
|
|
720
|
+
fetchImpl,
|
|
721
|
+
logger: silentLogger,
|
|
722
|
+
});
|
|
723
|
+
registerTranscriptionHook(hooks, worker, () => "default");
|
|
724
|
+
|
|
725
|
+
try {
|
|
726
|
+
const start = Date.now();
|
|
727
|
+
await hookedStore.addAttachment("h1", "memos/h1.webm", "audio/webm", {
|
|
728
|
+
transcribe_status: "pending",
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
// Poll for completion rather than sleep-and-hope — `queueMicrotask` +
|
|
732
|
+
// semaphore acquire + a faked fetch round-trip is well under 50ms but
|
|
733
|
+
// not zero.
|
|
734
|
+
const deadline = start + 500;
|
|
735
|
+
while (Date.now() < deadline) {
|
|
736
|
+
const [att] = await hookedStore.getAttachments("h1");
|
|
737
|
+
if (att?.metadata?.transcribe_status === "done") break;
|
|
738
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
739
|
+
}
|
|
740
|
+
const elapsed = Date.now() - start;
|
|
741
|
+
|
|
742
|
+
expect(callCount).toBe(1);
|
|
743
|
+
expect(elapsed).toBeLessThan(500);
|
|
744
|
+
|
|
745
|
+
const [att] = await hookedStore.getAttachments("h1");
|
|
746
|
+
expect(att!.metadata?.transcribe_status).toBe("done");
|
|
747
|
+
expect(att!.metadata?.transcript).toBe("hook-path");
|
|
748
|
+
|
|
749
|
+
const note = await hookedStore.getNote("h1");
|
|
750
|
+
expect(note!.content).toBe("hook-path");
|
|
751
|
+
} finally {
|
|
752
|
+
await worker.stop();
|
|
753
|
+
await hooks.drain();
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test("sweep still catches a backoff-queued item after its backoff elapses", async () => {
|
|
758
|
+
await hookedStore.createNote("stub", { id: "h2", metadata: { transcribe_stub: true } });
|
|
759
|
+
seedAudio("memos/h2.webm");
|
|
760
|
+
|
|
761
|
+
// Seed an attachment already in backoff, but with a backoff window that
|
|
762
|
+
// has already elapsed — the sweep should pick it up on the next tick.
|
|
763
|
+
// The hook is registered below, AFTER this insert, so the dispatch at
|
|
764
|
+
// addAttachment time has no subscribers and the event-driven path is
|
|
765
|
+
// never taken. What drives the completion is `worker.tick()` alone.
|
|
766
|
+
const past = new Date(Date.now() - 1_000).toISOString();
|
|
767
|
+
await hookedStore.addAttachment("h2", "memos/h2.webm", "audio/webm", {
|
|
768
|
+
transcribe_status: "pending",
|
|
769
|
+
transcribe_attempts: 1,
|
|
770
|
+
transcribe_backoff_until: past,
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
let calls = 0;
|
|
774
|
+
const fetchImpl = (async () => {
|
|
775
|
+
calls++;
|
|
776
|
+
return new Response(JSON.stringify({ text: "sweep-recovered" }), { status: 200 });
|
|
777
|
+
}) as unknown as typeof fetch;
|
|
778
|
+
|
|
779
|
+
const worker = startTranscriptionWorker({
|
|
780
|
+
vaultList: () => ["default"],
|
|
781
|
+
getStore: () => hookedStore as unknown as Store,
|
|
782
|
+
scribeUrl: "http://scribe.test",
|
|
783
|
+
resolveAssetsDir: () => assetsRoot,
|
|
784
|
+
pollIntervalMs: 10_000_000,
|
|
785
|
+
fetchImpl,
|
|
786
|
+
logger: silentLogger,
|
|
787
|
+
});
|
|
788
|
+
// Hook is registered but won't fire (no new addAttachment inside this
|
|
789
|
+
// test window). The sweep is what we're exercising.
|
|
790
|
+
registerTranscriptionHook(hooks, worker, () => "default");
|
|
791
|
+
|
|
792
|
+
try {
|
|
793
|
+
const processed = await worker.tick();
|
|
794
|
+
expect(processed).toBe(1);
|
|
795
|
+
expect(calls).toBe(1);
|
|
796
|
+
|
|
797
|
+
const [att] = await hookedStore.getAttachments("h2");
|
|
798
|
+
expect(att!.metadata?.transcribe_status).toBe("done");
|
|
799
|
+
expect(att!.metadata?.transcript).toBe("sweep-recovered");
|
|
800
|
+
} finally {
|
|
801
|
+
await worker.stop();
|
|
802
|
+
await hooks.drain();
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test("back-compat: pending status set without dispatching a hook is picked up by the sweep", async () => {
|
|
807
|
+
// Simulate a row inserted by something other than the hooked store —
|
|
808
|
+
// e.g., a restart resumes with a pre-existing pending attachment, or a
|
|
809
|
+
// migration/backfill that writes directly. The sweep must still drain
|
|
810
|
+
// it even though no `attachment:created` event was dispatched.
|
|
811
|
+
await hookedStore.createNote("stub", { id: "h3", metadata: { transcribe_stub: true } });
|
|
812
|
+
seedAudio("memos/h3.webm");
|
|
813
|
+
|
|
814
|
+
// Insert the attachment directly via raw SQL so no hook dispatches.
|
|
815
|
+
const now = new Date().toISOString();
|
|
816
|
+
hookedDb
|
|
817
|
+
.prepare(
|
|
818
|
+
"INSERT INTO attachments (id, note_id, path, mime_type, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
819
|
+
)
|
|
820
|
+
.run(
|
|
821
|
+
"att-h3",
|
|
822
|
+
"h3",
|
|
823
|
+
"memos/h3.webm",
|
|
824
|
+
"audio/webm",
|
|
825
|
+
JSON.stringify({ transcribe_status: "pending" }),
|
|
826
|
+
now,
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
let calls = 0;
|
|
830
|
+
const fetchImpl = (async () => {
|
|
831
|
+
calls++;
|
|
832
|
+
return new Response(JSON.stringify({ text: "back-compat-sweep" }), { status: 200 });
|
|
833
|
+
}) as unknown as typeof fetch;
|
|
834
|
+
|
|
835
|
+
const worker = startTranscriptionWorker({
|
|
836
|
+
vaultList: () => ["default"],
|
|
837
|
+
getStore: () => hookedStore as unknown as Store,
|
|
838
|
+
scribeUrl: "http://scribe.test",
|
|
839
|
+
resolveAssetsDir: () => assetsRoot,
|
|
840
|
+
pollIntervalMs: 10_000_000,
|
|
841
|
+
fetchImpl,
|
|
842
|
+
logger: silentLogger,
|
|
843
|
+
});
|
|
844
|
+
registerTranscriptionHook(hooks, worker, () => "default");
|
|
845
|
+
|
|
846
|
+
try {
|
|
847
|
+
// No hook fires — row was inserted via raw SQL. Prove the hook is idle.
|
|
848
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
849
|
+
expect(calls).toBe(0);
|
|
850
|
+
|
|
851
|
+
// Sweep tick drains it.
|
|
852
|
+
const processed = await worker.tick();
|
|
853
|
+
expect(processed).toBe(1);
|
|
854
|
+
expect(calls).toBe(1);
|
|
855
|
+
|
|
856
|
+
const [att] = await hookedStore.getAttachments("h3");
|
|
857
|
+
expect(att!.metadata?.transcribe_status).toBe("done");
|
|
858
|
+
expect(att!.metadata?.transcript).toBe("back-compat-sweep");
|
|
859
|
+
} finally {
|
|
860
|
+
await worker.stop();
|
|
861
|
+
await hooks.drain();
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
});
|