@openparachute/vault 0.4.7-rc.2 → 0.4.8-rc.6
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/.parachute/module.json +0 -1
- package/README.md +44 -10
- package/core/src/connection-pragmas.test.ts +232 -0
- package/core/src/core.test.ts +257 -0
- package/core/src/cursor.test.ts +160 -0
- package/core/src/cursor.ts +272 -0
- package/core/src/mcp.ts +51 -7
- package/core/src/notes.ts +164 -2
- package/core/src/schema.ts +98 -2
- package/core/src/store.ts +11 -1
- package/core/src/types.ts +32 -0
- package/package.json +1 -1
- package/src/auth-status.ts +4 -0
- package/src/auto-transcribe.test.ts +116 -0
- package/src/auto-transcribe.ts +48 -0
- package/src/cli.ts +57 -48
- package/src/config.test.ts +26 -0
- package/src/config.ts +53 -1
- package/src/db.ts +15 -2
- package/src/mcp-install-interactive.test.ts +23 -2
- package/src/mcp-install-interactive.ts +21 -2
- package/src/mcp-install.test.ts +40 -0
- package/src/mcp-tools.ts +17 -1
- package/src/module-config.ts +70 -14
- package/src/module-manifest.test.ts +114 -0
- package/src/module-manifest.ts +104 -0
- package/src/routes.ts +268 -51
- package/src/routing.test.ts +4 -2
- package/src/routing.ts +4 -4
- package/src/scribe-discovery.test.ts +77 -0
- package/src/scribe-discovery.ts +91 -0
- package/src/scribe-env.test.ts +66 -1
- package/src/scribe-env.ts +42 -1
- package/src/self-register.test.ts +379 -0
- package/src/self-register.ts +234 -0
- package/src/server.ts +46 -11
- package/src/transcript-note.test.ts +171 -0
- package/src/transcript-note.ts +189 -0
- package/src/transcription-registry.ts +22 -0
- package/src/transcription-worker.test.ts +250 -0
- package/src/transcription-worker.ts +186 -27
- package/src/vault.test.ts +347 -0
|
@@ -54,6 +54,7 @@ import type { Store, Attachment } from "../core/src/types.ts";
|
|
|
54
54
|
import type { HookRegistry } from "../core/src/hooks.ts";
|
|
55
55
|
import { appendContextPart, fetchContextEntries, type ContextPayload } from "./context.ts";
|
|
56
56
|
import type { TriggerIncludeContext } from "./config.ts";
|
|
57
|
+
import { upsertTranscriptNote } from "./transcript-note.ts";
|
|
57
58
|
|
|
58
59
|
/** Placeholder pattern written by Lens's voice-memo stub. */
|
|
59
60
|
const TRANSCRIPT_PLACEHOLDER = /_Transcript pending\._/;
|
|
@@ -139,9 +140,38 @@ interface PendingMeta {
|
|
|
139
140
|
transcribe_error?: string;
|
|
140
141
|
transcript?: string;
|
|
141
142
|
transcribe_done_at?: string;
|
|
143
|
+
/**
|
|
144
|
+
* Marker stamped by the attachment-write code path (vault#353) when the
|
|
145
|
+
* audio attachment was queued via the auto-transcribe pipeline (mime-type
|
|
146
|
+
* matched `audio/*` AND `autoTranscribe.enabled === true`). When set to
|
|
147
|
+
* `"auto"`, the worker materializes a `<attachment-path>.transcript.md`
|
|
148
|
+
* note on terminal states (success OR failure) so the transcript surface
|
|
149
|
+
* is uniform regardless of outcome. Absent or set to `"legacy"`, the
|
|
150
|
+
* worker preserves the original stub-patching behavior (Lens flow).
|
|
151
|
+
*/
|
|
152
|
+
transcribe_origin?: "auto" | "legacy";
|
|
142
153
|
[k: string]: unknown;
|
|
143
154
|
}
|
|
144
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Structured error thrown when scribe returns a 4xx with a recognized
|
|
158
|
+
* `error_code` — we surface the code on the transcript note's frontmatter
|
|
159
|
+
* so callers can branch on stable strings instead of regex-matching message
|
|
160
|
+
* text. Today the canonical code is `missing_provider` (scribe#47).
|
|
161
|
+
*/
|
|
162
|
+
class ScribeApiError extends Error {
|
|
163
|
+
readonly errorCode?: string;
|
|
164
|
+
readonly httpStatus: number;
|
|
165
|
+
readonly retriable: boolean;
|
|
166
|
+
constructor(message: string, opts: { errorCode?: string; httpStatus: number; retriable: boolean }) {
|
|
167
|
+
super(message);
|
|
168
|
+
this.name = "ScribeApiError";
|
|
169
|
+
this.errorCode = opts.errorCode;
|
|
170
|
+
this.httpStatus = opts.httpStatus;
|
|
171
|
+
this.retriable = opts.retriable;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
145
175
|
/**
|
|
146
176
|
* Start the worker loop. Returns a handle with `stop()` + `tick()`.
|
|
147
177
|
* Tests should build the worker and call `tick()` directly; production
|
|
@@ -216,6 +246,38 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
|
|
|
216
246
|
}
|
|
217
247
|
}
|
|
218
248
|
|
|
249
|
+
/**
|
|
250
|
+
* On a terminal failure for an attachment with `transcribe_origin: "auto"`,
|
|
251
|
+
* write (or update) a `<attachment-path>.transcript.md` note with
|
|
252
|
+
* `transcript_status: failed` so the user has a queryable record of the
|
|
253
|
+
* failure and a target for the retry endpoint. Best-effort: any error
|
|
254
|
+
* materializing the transcript note is logged, never propagated — the
|
|
255
|
+
* attachment metadata write is the durable record of failure.
|
|
256
|
+
*/
|
|
257
|
+
async function writeFailureTranscriptNote(
|
|
258
|
+
store: Store,
|
|
259
|
+
attachment: Attachment,
|
|
260
|
+
errMsg: string,
|
|
261
|
+
errorCode: string | undefined,
|
|
262
|
+
durationMs: number | undefined,
|
|
263
|
+
): Promise<void> {
|
|
264
|
+
try {
|
|
265
|
+
await upsertTranscriptNote(store, {
|
|
266
|
+
attachmentPath: attachment.path,
|
|
267
|
+
attachmentId: attachment.id,
|
|
268
|
+
attachmentNoteId: attachment.noteId,
|
|
269
|
+
status: "failed",
|
|
270
|
+
error: errorCode ? `${errorCode}: ${errMsg}` : errMsg,
|
|
271
|
+
durationMs,
|
|
272
|
+
});
|
|
273
|
+
} catch (err) {
|
|
274
|
+
logger.error(
|
|
275
|
+
`[transcribe] failed to write failure transcript note for attachment ${attachment.id}:`,
|
|
276
|
+
err,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
219
281
|
async function processOneLocked(vault: string, attachment: Attachment): Promise<void> {
|
|
220
282
|
const store = opts.getStore(vault);
|
|
221
283
|
// Re-read metadata — the in-memory `attachment` may be stale (the hook
|
|
@@ -226,6 +288,10 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
|
|
|
226
288
|
if (meta.transcribe_status !== "pending") return;
|
|
227
289
|
|
|
228
290
|
const attempts = (meta.transcribe_attempts as number | undefined) ?? 0;
|
|
291
|
+
// Whether to materialize a transcript note (vault#353 auto-transcribe path)
|
|
292
|
+
// vs. the legacy stub-patching path (Lens flow). Auto-write notes also
|
|
293
|
+
// surface failures so the user can retry from the transcript note.
|
|
294
|
+
const isAutoOrigin = meta.transcribe_origin === "auto";
|
|
229
295
|
|
|
230
296
|
// Honor backoff — we re-check here in case another tick queued this
|
|
231
297
|
// attachment between the listing and now.
|
|
@@ -243,7 +309,11 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
|
|
|
243
309
|
transcribe_status: "failed",
|
|
244
310
|
transcribe_error: "audio file not found",
|
|
245
311
|
});
|
|
246
|
-
|
|
312
|
+
if (isAutoOrigin) {
|
|
313
|
+
await writeFailureTranscriptNote(store, attachment, "audio file not found", undefined, undefined);
|
|
314
|
+
} else {
|
|
315
|
+
await applyFailureMarker(store, attachment.noteId);
|
|
316
|
+
}
|
|
247
317
|
return;
|
|
248
318
|
}
|
|
249
319
|
|
|
@@ -256,9 +326,9 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
|
|
|
256
326
|
context = await fetchContextEntries(store, predicates, logger);
|
|
257
327
|
}
|
|
258
328
|
|
|
259
|
-
let
|
|
329
|
+
let scribeResult: { text: string; durationMs: number };
|
|
260
330
|
try {
|
|
261
|
-
|
|
331
|
+
scribeResult = await callScribe({
|
|
262
332
|
url: opts.scribeUrl,
|
|
263
333
|
token: opts.scribeToken,
|
|
264
334
|
filePath,
|
|
@@ -269,17 +339,34 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
|
|
|
269
339
|
fetchImpl,
|
|
270
340
|
});
|
|
271
341
|
} catch (err) {
|
|
272
|
-
const nextAttempts = attempts + 1;
|
|
273
342
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
274
|
-
|
|
275
|
-
|
|
343
|
+
const apiErr = err instanceof ScribeApiError ? err : null;
|
|
344
|
+
// 4xx with structured error code → terminal immediately. Re-POSTing the
|
|
345
|
+
// same audio at a scribe with no provider configured (or that rejects
|
|
346
|
+
// our bearer) will keep failing — the operator has to act, retries don't
|
|
347
|
+
// help. This is the "graceful first-boot path" from design Q5.
|
|
348
|
+
const nonRetriable = apiErr !== null && !apiErr.retriable;
|
|
349
|
+
const nextAttempts = attempts + 1;
|
|
350
|
+
const terminal = nonRetriable || nextAttempts >= maxAttempts;
|
|
351
|
+
|
|
352
|
+
if (terminal) {
|
|
353
|
+
if (nonRetriable) {
|
|
354
|
+
logger.error(`[transcribe] non-retriable scribe error on attachment ${attachment.id} (status ${apiErr!.httpStatus}${apiErr!.errorCode ? `, ${apiErr!.errorCode}` : ""}):`, errMsg);
|
|
355
|
+
} else {
|
|
356
|
+
logger.error(`[transcribe] giving up on attachment ${attachment.id} after ${nextAttempts} attempts:`, errMsg);
|
|
357
|
+
}
|
|
276
358
|
await store.setAttachmentMetadata(attachment.id, {
|
|
277
359
|
...meta,
|
|
278
360
|
transcribe_status: "failed",
|
|
279
361
|
transcribe_attempts: nextAttempts,
|
|
280
362
|
transcribe_error: errMsg,
|
|
363
|
+
...(apiErr?.errorCode ? { transcribe_error_code: apiErr.errorCode } : {}),
|
|
281
364
|
});
|
|
282
|
-
|
|
365
|
+
if (isAutoOrigin) {
|
|
366
|
+
await writeFailureTranscriptNote(store, attachment, errMsg, apiErr?.errorCode, undefined);
|
|
367
|
+
} else {
|
|
368
|
+
await applyFailureMarker(store, attachment.noteId);
|
|
369
|
+
}
|
|
283
370
|
// retention=never drops the audio on any terminal state, including
|
|
284
371
|
// failure. The user opted in to "I don't want the audio kept around
|
|
285
372
|
// regardless of outcome" — honor it.
|
|
@@ -302,23 +389,46 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
|
|
|
302
389
|
return;
|
|
303
390
|
}
|
|
304
391
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
392
|
+
const { text: transcript, durationMs } = scribeResult;
|
|
393
|
+
|
|
394
|
+
// Auto-origin success: materialize the transcript note (vault#353). The
|
|
395
|
+
// note's path is `<attachment-path>.transcript.md`, its frontmatter links
|
|
396
|
+
// back to the audio attachment via `transcript_of`.
|
|
397
|
+
if (isAutoOrigin) {
|
|
398
|
+
try {
|
|
399
|
+
await upsertTranscriptNote(store, {
|
|
400
|
+
attachmentPath: attachment.path,
|
|
401
|
+
attachmentId: attachment.id,
|
|
402
|
+
attachmentNoteId: attachment.noteId,
|
|
403
|
+
status: "complete",
|
|
404
|
+
text: transcript,
|
|
405
|
+
durationMs,
|
|
406
|
+
});
|
|
407
|
+
} catch (err) {
|
|
408
|
+
// Note write failure doesn't invalidate the transcript — it's still
|
|
409
|
+
// stored on the attachment row below. Log + continue so retention
|
|
410
|
+
// still applies and the attachment row reflects success.
|
|
411
|
+
logger.error(`[transcribe] failed to write transcript note for attachment ${attachment.id}:`, err);
|
|
412
|
+
}
|
|
413
|
+
} else {
|
|
414
|
+
// Legacy stub-patching path (Lens voice memo flow).
|
|
415
|
+
const note = await store.getNote(attachment.noteId);
|
|
416
|
+
if (note) {
|
|
417
|
+
const noteMeta = (note.metadata as Record<string, unknown> | undefined) ?? {};
|
|
418
|
+
if (noteMeta.transcribe_stub === true) {
|
|
419
|
+
const body = TRANSCRIPT_PLACEHOLDER.test(note.content)
|
|
420
|
+
? note.content.replace(TRANSCRIPT_PLACEHOLDER, transcript)
|
|
421
|
+
: transcript;
|
|
422
|
+
const { transcribe_stub: _drop, ...restMeta } = noteMeta;
|
|
423
|
+
try {
|
|
424
|
+
await store.updateNote(note.id, {
|
|
425
|
+
content: body,
|
|
426
|
+
metadata: restMeta,
|
|
427
|
+
skipUpdatedAt: true,
|
|
428
|
+
});
|
|
429
|
+
} catch (err) {
|
|
430
|
+
logger.error(`[transcribe] failed to apply transcript to note ${note.id}:`, err);
|
|
431
|
+
}
|
|
322
432
|
}
|
|
323
433
|
}
|
|
324
434
|
}
|
|
@@ -330,10 +440,12 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
|
|
|
330
440
|
transcribe_status: "done",
|
|
331
441
|
transcribe_attempts: attempts + 1,
|
|
332
442
|
transcribe_done_at: new Date().toISOString(),
|
|
443
|
+
transcribe_duration_ms: durationMs,
|
|
333
444
|
transcript,
|
|
334
445
|
};
|
|
335
446
|
delete doneMeta.transcribe_backoff_until;
|
|
336
447
|
delete doneMeta.transcribe_error;
|
|
448
|
+
delete doneMeta.transcribe_error_code;
|
|
337
449
|
await store.setAttachmentMetadata(attachment.id, doneMeta);
|
|
338
450
|
|
|
339
451
|
// Retention: drop the file but keep the row so the transcript stays
|
|
@@ -458,6 +570,24 @@ export function registerTranscriptionHook(
|
|
|
458
570
|
});
|
|
459
571
|
}
|
|
460
572
|
|
|
573
|
+
/**
|
|
574
|
+
* Call scribe's `POST /v1/audio/transcriptions` with the audio file + optional
|
|
575
|
+
* context part. Returns the transcript text plus the wall-clock duration of
|
|
576
|
+
* the request, so the worker can surface `transcript_duration_ms` on the
|
|
577
|
+
* transcript note.
|
|
578
|
+
*
|
|
579
|
+
* Failure modes (encoded as throws):
|
|
580
|
+
* - 4xx with a JSON body carrying `error_code`: throws `ScribeApiError`
|
|
581
|
+
* with the code (`missing_provider` etc.). Treated as a non-retriable
|
|
582
|
+
* terminal failure — re-POSTing the same audio at the same broken scribe
|
|
583
|
+
* would just fail the same way; the operator has to act.
|
|
584
|
+
* - 4xx without `error_code` (auth, malformed multipart): throws
|
|
585
|
+
* `ScribeApiError` with the body text. Non-retriable.
|
|
586
|
+
* - 5xx, network error, or timeout: throws a plain `Error`. Retriable —
|
|
587
|
+
* the worker's backoff path picks it up.
|
|
588
|
+
* - 200 with missing/invalid `text` field: throws a plain `Error`.
|
|
589
|
+
* Retriable (could be a transient provider-output glitch).
|
|
590
|
+
*/
|
|
461
591
|
async function callScribe(args: {
|
|
462
592
|
url: string;
|
|
463
593
|
token?: string;
|
|
@@ -467,9 +597,10 @@ async function callScribe(args: {
|
|
|
467
597
|
context: ContextPayload | null;
|
|
468
598
|
timeoutMs: number;
|
|
469
599
|
fetchImpl: typeof fetch;
|
|
470
|
-
}): Promise<string> {
|
|
600
|
+
}): Promise<{ text: string; durationMs: number }> {
|
|
471
601
|
const controller = new AbortController();
|
|
472
602
|
const timer = setTimeout(() => controller.abort(), args.timeoutMs);
|
|
603
|
+
const startedAt = Date.now();
|
|
473
604
|
try {
|
|
474
605
|
const fileBuffer = readFileSync(args.filePath);
|
|
475
606
|
const file = new File([fileBuffer], args.filename, { type: args.mimeType });
|
|
@@ -488,14 +619,42 @@ async function callScribe(args: {
|
|
|
488
619
|
signal: controller.signal,
|
|
489
620
|
});
|
|
490
621
|
if (!resp.ok) {
|
|
491
|
-
|
|
622
|
+
const body = await resp.text().catch(() => "");
|
|
623
|
+
// Try to extract structured error_code from JSON body (scribe#47).
|
|
624
|
+
let errorCode: string | undefined;
|
|
625
|
+
let errorMessage: string | undefined;
|
|
626
|
+
try {
|
|
627
|
+
const parsed = JSON.parse(body) as { error?: string; error_code?: string; message?: string };
|
|
628
|
+
if (typeof parsed.error_code === "string") errorCode = parsed.error_code;
|
|
629
|
+
if (typeof parsed.error === "string") errorMessage = parsed.error;
|
|
630
|
+
else if (typeof parsed.message === "string") errorMessage = parsed.message;
|
|
631
|
+
} catch {
|
|
632
|
+
// Not JSON — leave errorCode undefined; the raw body becomes the message.
|
|
633
|
+
}
|
|
634
|
+
// 4xx is terminal (re-POSTing the same audio at the same broken scribe
|
|
635
|
+
// will just fail again). 5xx is retriable — provider hiccup, will likely
|
|
636
|
+
// succeed on backoff.
|
|
637
|
+
const retriable = resp.status >= 500;
|
|
638
|
+
const message = errorMessage
|
|
639
|
+
?? (errorCode ? `scribe ${errorCode}` : `scribe returned ${resp.status}: ${body}`);
|
|
640
|
+
throw new ScribeApiError(message, {
|
|
641
|
+
errorCode,
|
|
642
|
+
httpStatus: resp.status,
|
|
643
|
+
retriable,
|
|
644
|
+
});
|
|
492
645
|
}
|
|
493
646
|
const result = await resp.json() as { text?: string };
|
|
494
647
|
if (typeof result.text !== "string") {
|
|
495
648
|
throw new Error("scribe response missing text field");
|
|
496
649
|
}
|
|
497
|
-
return result.text;
|
|
650
|
+
return { text: result.text, durationMs: Date.now() - startedAt };
|
|
498
651
|
} finally {
|
|
499
652
|
clearTimeout(timer);
|
|
500
653
|
}
|
|
501
654
|
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Re-export the structured error type so tests + callers can `instanceof`-check
|
|
658
|
+
* for terminal-failure semantics.
|
|
659
|
+
*/
|
|
660
|
+
export { ScribeApiError };
|
package/src/vault.test.ts
CHANGED
|
@@ -1575,6 +1575,177 @@ describe("HTTP /notes", async () => {
|
|
|
1575
1575
|
expect(body.map((n) => n.content)).toEqual(["modified"]);
|
|
1576
1576
|
});
|
|
1577
1577
|
|
|
1578
|
+
// ---- Cursor pagination (vault#313) ----
|
|
1579
|
+
//
|
|
1580
|
+
// Opaque cursors for since-last-checked agent loops. The full engine
|
|
1581
|
+
// semantics live in core.test.ts; these tests pin the HTTP plumbing:
|
|
1582
|
+
// wrapped {notes, next_cursor} envelope when ?cursor= is set, structured
|
|
1583
|
+
// 400s on bad cursor, and end-to-end resume across calls.
|
|
1584
|
+
test("GET /notes?cursor=... returns {notes, next_cursor} envelope", async () => {
|
|
1585
|
+
await store.createNote("a", { id: "cur-rest-a" });
|
|
1586
|
+
await store.createNote("b", { id: "cur-rest-b" });
|
|
1587
|
+
db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
|
|
1588
|
+
.run("2026-04-01T00:00:00.000Z", "cur-rest-a");
|
|
1589
|
+
db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
|
|
1590
|
+
.run("2026-04-02T00:00:00.000Z", "cur-rest-b");
|
|
1591
|
+
|
|
1592
|
+
// Mint a starting cursor via the store (we don't expose a "first cursor"
|
|
1593
|
+
// endpoint — the first call's response carries the cursor). Simulate
|
|
1594
|
+
// the first call by querying without a cursor and reading
|
|
1595
|
+
// next_cursor from a follow-up call shape.
|
|
1596
|
+
const seed = await store.queryNotesPaged({});
|
|
1597
|
+
const cursor = seed.next_cursor;
|
|
1598
|
+
|
|
1599
|
+
// No new writes after seed → second call is empty but still returns
|
|
1600
|
+
// an envelope.
|
|
1601
|
+
const res = await handleNotes(
|
|
1602
|
+
mkReq("GET", `/notes?cursor=${encodeURIComponent(cursor)}`),
|
|
1603
|
+
store,
|
|
1604
|
+
"",
|
|
1605
|
+
);
|
|
1606
|
+
expect(res.status).toBe(200);
|
|
1607
|
+
const body = await res.json() as any;
|
|
1608
|
+
expect(body).toHaveProperty("notes");
|
|
1609
|
+
expect(body).toHaveProperty("next_cursor");
|
|
1610
|
+
expect(Array.isArray(body.notes)).toBe(true);
|
|
1611
|
+
expect(body.notes).toHaveLength(0);
|
|
1612
|
+
expect(typeof body.next_cursor).toBe("string");
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1615
|
+
test("GET /notes (no cursor) returns legacy flat-array shape", async () => {
|
|
1616
|
+
await store.createNote("a", { id: "noCur-a" });
|
|
1617
|
+
const res = await handleNotes(mkReq("GET", "/notes"), store, "");
|
|
1618
|
+
const body = await res.json();
|
|
1619
|
+
expect(Array.isArray(body)).toBe(true);
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
test("GET /notes?cursor=<stale> returns 400 cursor_query_mismatch", async () => {
|
|
1623
|
+
await store.createNote("a", { tags: ["x"], id: "cm-a" });
|
|
1624
|
+
await store.createNote("b", { tags: ["y"], id: "cm-b" });
|
|
1625
|
+
const seed = await store.queryNotesPaged({ tags: ["x"] });
|
|
1626
|
+
|
|
1627
|
+
// Reuse on a different tag — engine raises cursor_query_mismatch.
|
|
1628
|
+
const res = await handleNotes(
|
|
1629
|
+
mkReq("GET", `/notes?tag=y&cursor=${encodeURIComponent(seed.next_cursor)}`),
|
|
1630
|
+
store,
|
|
1631
|
+
"",
|
|
1632
|
+
);
|
|
1633
|
+
expect(res.status).toBe(400);
|
|
1634
|
+
const body = await res.json() as any;
|
|
1635
|
+
expect(body.code).toBe("cursor_query_mismatch");
|
|
1636
|
+
});
|
|
1637
|
+
|
|
1638
|
+
test("GET /notes?cursor=<garbage> returns 400 cursor_invalid", async () => {
|
|
1639
|
+
const res = await handleNotes(
|
|
1640
|
+
mkReq("GET", `/notes?cursor=not-a-real-cursor`),
|
|
1641
|
+
store,
|
|
1642
|
+
"",
|
|
1643
|
+
);
|
|
1644
|
+
expect(res.status).toBe(400);
|
|
1645
|
+
const body = await res.json() as any;
|
|
1646
|
+
expect(body.code).toBe("cursor_invalid");
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1649
|
+
test("GET /notes?cursor=...&near[note_id]=x rejects with INVALID_QUERY", async () => {
|
|
1650
|
+
const a = await store.createNote("anchor", { id: "n1" });
|
|
1651
|
+
const seed = await store.queryNotesPaged({});
|
|
1652
|
+
const res = await handleNotes(
|
|
1653
|
+
mkReq("GET", `/notes?cursor=${encodeURIComponent(seed.next_cursor)}&near[note_id]=${a.id}`),
|
|
1654
|
+
store,
|
|
1655
|
+
"",
|
|
1656
|
+
);
|
|
1657
|
+
expect(res.status).toBe(400);
|
|
1658
|
+
const body = await res.json() as any;
|
|
1659
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
test("GET /notes?cursor=...&search=... rejects with INVALID_QUERY (vault#355 reviewer)", async () => {
|
|
1663
|
+
// REST used to silently drop the cursor and route into the FTS branch.
|
|
1664
|
+
// MCP rejects this combo explicitly at core/src/mcp.ts — REST now does
|
|
1665
|
+
// the same. Surface parity, no silent corruption.
|
|
1666
|
+
await store.createNote("the quick brown fox", { id: "s1" });
|
|
1667
|
+
const seed = await store.queryNotesPaged({});
|
|
1668
|
+
const res = await handleNotes(
|
|
1669
|
+
mkReq("GET", `/notes?cursor=${encodeURIComponent(seed.next_cursor)}&search=fox`),
|
|
1670
|
+
store,
|
|
1671
|
+
"",
|
|
1672
|
+
);
|
|
1673
|
+
expect(res.status).toBe(400);
|
|
1674
|
+
const body = await res.json() as any;
|
|
1675
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
1676
|
+
});
|
|
1677
|
+
|
|
1678
|
+
test("GET /notes?cursor=...&sort=desc rejects with INVALID_QUERY", async () => {
|
|
1679
|
+
// Descending iteration with a watermark cursor would skip newly-written
|
|
1680
|
+
// rows. queryNotesPaged surfaces this as a QueryError; the REST handler
|
|
1681
|
+
// catches and translates to 400. Asserts the surface parity with MCP
|
|
1682
|
+
// even though the guard sits at the core layer.
|
|
1683
|
+
await store.createNote("a", { id: "sd-a" });
|
|
1684
|
+
const seed = await store.queryNotesPaged({});
|
|
1685
|
+
const res = await handleNotes(
|
|
1686
|
+
mkReq("GET", `/notes?cursor=${encodeURIComponent(seed.next_cursor)}&sort=desc`),
|
|
1687
|
+
store,
|
|
1688
|
+
"",
|
|
1689
|
+
);
|
|
1690
|
+
expect(res.status).toBe(400);
|
|
1691
|
+
const body = await res.json() as any;
|
|
1692
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1695
|
+
test("GET /notes?cursor=...&order_by=... rejects with INVALID_QUERY", async () => {
|
|
1696
|
+
// Cursor pagination forces order by updated_at; order_by is mutually
|
|
1697
|
+
// exclusive. Same surface-parity assertion as the sort=desc test.
|
|
1698
|
+
await store.createNote("a", { id: "ob-a" });
|
|
1699
|
+
const seed = await store.queryNotesPaged({});
|
|
1700
|
+
const res = await handleNotes(
|
|
1701
|
+
mkReq("GET", `/notes?cursor=${encodeURIComponent(seed.next_cursor)}&order_by=created_at`),
|
|
1702
|
+
store,
|
|
1703
|
+
"",
|
|
1704
|
+
);
|
|
1705
|
+
expect(res.status).toBe(400);
|
|
1706
|
+
const body = await res.json() as any;
|
|
1707
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
1708
|
+
});
|
|
1709
|
+
|
|
1710
|
+
test("GET /notes?search=fox (without cursor) still works after the cursor+search guard", async () => {
|
|
1711
|
+
// Sanity check: the cursor+search guard must not regress plain FTS.
|
|
1712
|
+
await store.createNote("the quick brown fox", { id: "ss-a" });
|
|
1713
|
+
const res = await handleNotes(mkReq("GET", "/notes?search=fox"), store, "");
|
|
1714
|
+
expect(res.status).toBe(200);
|
|
1715
|
+
const body = await res.json() as any[];
|
|
1716
|
+
expect(body).toHaveLength(1);
|
|
1717
|
+
expect(body[0].id).toBe("ss-a");
|
|
1718
|
+
});
|
|
1719
|
+
|
|
1720
|
+
test("GET /notes?cursor=... resumes correctly across calls (end-to-end)", async () => {
|
|
1721
|
+
// Three notes spread across distinct updated_at watermarks. First
|
|
1722
|
+
// call returns the first batch, second call (with cursor) returns
|
|
1723
|
+
// only the note written after the cursor was minted.
|
|
1724
|
+
const a = await store.createNote("first", { id: "e2e-a" });
|
|
1725
|
+
db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
|
|
1726
|
+
.run("2026-04-01T00:00:00.000Z", a.id);
|
|
1727
|
+
const b = await store.createNote("second", { id: "e2e-b" });
|
|
1728
|
+
db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
|
|
1729
|
+
.run("2026-04-02T00:00:00.000Z", b.id);
|
|
1730
|
+
|
|
1731
|
+
const seed = await store.queryNotesPaged({});
|
|
1732
|
+
expect(seed.notes.map((n) => n.id).sort()).toEqual(["e2e-a", "e2e-b"]);
|
|
1733
|
+
|
|
1734
|
+
// Write a third note that lands AFTER the cursor's watermark.
|
|
1735
|
+
const c = await store.createNote("third", { id: "e2e-c" });
|
|
1736
|
+
db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
|
|
1737
|
+
.run("2026-04-03T00:00:00.000Z", c.id);
|
|
1738
|
+
|
|
1739
|
+
const res = await handleNotes(
|
|
1740
|
+
mkReq("GET", `/notes?cursor=${encodeURIComponent(seed.next_cursor)}&include_content=true`),
|
|
1741
|
+
store,
|
|
1742
|
+
"",
|
|
1743
|
+
);
|
|
1744
|
+
expect(res.status).toBe(200);
|
|
1745
|
+
const body = await res.json() as any;
|
|
1746
|
+
expect(body.notes.map((n: any) => n.id)).toEqual(["e2e-c"]);
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1578
1749
|
test("GET /notes?has_links=false returns only orphaned notes", async () => {
|
|
1579
1750
|
const a = await store.createNote("src", { id: "qa" });
|
|
1580
1751
|
const b = await store.createNote("tgt", { id: "qb" });
|
|
@@ -2543,6 +2714,182 @@ describe("HTTP /notes", async () => {
|
|
|
2543
2714
|
expect(body.map((n) => n.content)).toEqual(["new"]);
|
|
2544
2715
|
});
|
|
2545
2716
|
});
|
|
2717
|
+
|
|
2718
|
+
// -------------------------------------------------------------------------
|
|
2719
|
+
// POST /notes/:id/retry-transcription — vault#353 design Q5.
|
|
2720
|
+
//
|
|
2721
|
+
// The endpoint re-enqueues a failed transcript by flipping the matching
|
|
2722
|
+
// attachment back to `transcribe_status: pending`. The worker (or sweep)
|
|
2723
|
+
// picks it up. These tests exercise the request shape + error branches;
|
|
2724
|
+
// the actual re-transcription is covered in transcription-worker.test.ts's
|
|
2725
|
+
// auto-origin section.
|
|
2726
|
+
// -------------------------------------------------------------------------
|
|
2727
|
+
describe("POST /notes/:id/retry-transcription", async () => {
|
|
2728
|
+
async function seedFailedTranscript(opts: {
|
|
2729
|
+
audioPath?: string;
|
|
2730
|
+
noteId?: string;
|
|
2731
|
+
transcriptId?: string;
|
|
2732
|
+
omitAttachmentId?: boolean;
|
|
2733
|
+
} = {}): Promise<{ owner: { id: string }; attachmentId: string; transcriptId: string; audioPath: string }> {
|
|
2734
|
+
const audioPath = opts.audioPath ?? `${opts.noteId ?? "src"}/voice.webm`;
|
|
2735
|
+
const ownerId = opts.noteId ?? "src-note";
|
|
2736
|
+
const owner = await store.createNote("# Voice\n", { id: ownerId });
|
|
2737
|
+
const att = await store.addAttachment(owner.id, audioPath, "audio/webm", {
|
|
2738
|
+
transcribe_status: "failed",
|
|
2739
|
+
transcribe_origin: "auto",
|
|
2740
|
+
transcribe_error: "no transcription provider configured",
|
|
2741
|
+
});
|
|
2742
|
+
// Seed the failed transcript note exactly as the worker would have.
|
|
2743
|
+
const transcriptMeta: Record<string, unknown> = {
|
|
2744
|
+
transcript_of: audioPath,
|
|
2745
|
+
transcript_status: "failed",
|
|
2746
|
+
transcript_error: "missing_provider: no transcription provider configured",
|
|
2747
|
+
};
|
|
2748
|
+
if (!opts.omitAttachmentId) transcriptMeta.transcript_attachment_id = att.id;
|
|
2749
|
+
await store.createNote("", {
|
|
2750
|
+
id: opts.transcriptId ?? "transcript-1",
|
|
2751
|
+
path: `${audioPath}.transcript`,
|
|
2752
|
+
tags: ["transcript", "capture"],
|
|
2753
|
+
metadata: transcriptMeta,
|
|
2754
|
+
});
|
|
2755
|
+
// Audio file present on disk so the retry doesn't 404 on audio_missing.
|
|
2756
|
+
const assetsRoot = join(tmpDir, "assets");
|
|
2757
|
+
mkdirSync(join(assetsRoot, audioPath.split("/").slice(0, -1).join("/")), { recursive: true });
|
|
2758
|
+
writeFileSync(join(assetsRoot, audioPath), Buffer.from([1, 2, 3]));
|
|
2759
|
+
process.env.ASSETS_DIR = assetsRoot;
|
|
2760
|
+
return { owner: { id: ownerId }, attachmentId: att.id, transcriptId: opts.transcriptId ?? "transcript-1", audioPath };
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
test("happy path: flips attachment to pending and returns 202", async () => {
|
|
2764
|
+
const { attachmentId, transcriptId } = await seedFailedTranscript();
|
|
2765
|
+
const res = await handleNotes(
|
|
2766
|
+
mkReq("POST", `/notes/${transcriptId}/retry-transcription`),
|
|
2767
|
+
store,
|
|
2768
|
+
`/${transcriptId}/retry-transcription`,
|
|
2769
|
+
"default",
|
|
2770
|
+
);
|
|
2771
|
+
expect(res.status).toBe(202);
|
|
2772
|
+
const body = await res.json() as any;
|
|
2773
|
+
expect(body.status).toBe("queued");
|
|
2774
|
+
expect(body.attachment_id).toBe(attachmentId);
|
|
2775
|
+
expect(body.transcript_note_id).toBe(transcriptId);
|
|
2776
|
+
|
|
2777
|
+
// Attachment metadata reset.
|
|
2778
|
+
const att = await store.getAttachment(attachmentId);
|
|
2779
|
+
expect(att?.metadata?.transcribe_status).toBe("pending");
|
|
2780
|
+
expect(att?.metadata?.transcribe_origin).toBe("auto");
|
|
2781
|
+
// Stale failure state cleared.
|
|
2782
|
+
expect(att?.metadata?.transcribe_error).toBeUndefined();
|
|
2783
|
+
expect(att?.metadata?.transcribe_attempts).toBeUndefined();
|
|
2784
|
+
|
|
2785
|
+
delete process.env.ASSETS_DIR;
|
|
2786
|
+
});
|
|
2787
|
+
|
|
2788
|
+
test("404 when transcript note doesn't exist", async () => {
|
|
2789
|
+
const res = await handleNotes(
|
|
2790
|
+
mkReq("POST", "/notes/no-such-id/retry-transcription"),
|
|
2791
|
+
store,
|
|
2792
|
+
"/no-such-id/retry-transcription",
|
|
2793
|
+
"default",
|
|
2794
|
+
);
|
|
2795
|
+
expect(res.status).toBe(404);
|
|
2796
|
+
});
|
|
2797
|
+
|
|
2798
|
+
test("400 invalid_target when target is not a transcript note", async () => {
|
|
2799
|
+
await store.createNote("regular note", { id: "regular" });
|
|
2800
|
+
const res = await handleNotes(
|
|
2801
|
+
mkReq("POST", "/notes/regular/retry-transcription"),
|
|
2802
|
+
store,
|
|
2803
|
+
"/regular/retry-transcription",
|
|
2804
|
+
"default",
|
|
2805
|
+
);
|
|
2806
|
+
expect(res.status).toBe(400);
|
|
2807
|
+
const body = await res.json() as any;
|
|
2808
|
+
expect(body.error).toBe("invalid_target");
|
|
2809
|
+
});
|
|
2810
|
+
|
|
2811
|
+
test("400 not_failed when transcript already succeeded", async () => {
|
|
2812
|
+
const audioPath = "memo/done.webm";
|
|
2813
|
+
const owner = await store.createNote("voice", { id: "src-done" });
|
|
2814
|
+
const att = await store.addAttachment(owner.id, audioPath, "audio/webm");
|
|
2815
|
+
await store.createNote("the spoken words", {
|
|
2816
|
+
id: "transcript-done",
|
|
2817
|
+
path: `${audioPath}.transcript`,
|
|
2818
|
+
metadata: {
|
|
2819
|
+
transcript_of: audioPath,
|
|
2820
|
+
transcript_attachment_id: att.id,
|
|
2821
|
+
transcript_status: "complete",
|
|
2822
|
+
},
|
|
2823
|
+
});
|
|
2824
|
+
const res = await handleNotes(
|
|
2825
|
+
mkReq("POST", "/notes/transcript-done/retry-transcription"),
|
|
2826
|
+
store,
|
|
2827
|
+
"/transcript-done/retry-transcription",
|
|
2828
|
+
"default",
|
|
2829
|
+
);
|
|
2830
|
+
expect(res.status).toBe(400);
|
|
2831
|
+
const body = await res.json() as any;
|
|
2832
|
+
expect(body.error).toBe("not_failed");
|
|
2833
|
+
expect(body.transcript_status).toBe("complete");
|
|
2834
|
+
});
|
|
2835
|
+
|
|
2836
|
+
test("400 missing_attachment_id when frontmatter lacks the id", async () => {
|
|
2837
|
+
await seedFailedTranscript({
|
|
2838
|
+
transcriptId: "transcript-no-id",
|
|
2839
|
+
noteId: "src-no-id",
|
|
2840
|
+
omitAttachmentId: true,
|
|
2841
|
+
});
|
|
2842
|
+
const res = await handleNotes(
|
|
2843
|
+
mkReq("POST", "/notes/transcript-no-id/retry-transcription"),
|
|
2844
|
+
store,
|
|
2845
|
+
"/transcript-no-id/retry-transcription",
|
|
2846
|
+
"default",
|
|
2847
|
+
);
|
|
2848
|
+
expect(res.status).toBe(400);
|
|
2849
|
+
const body = await res.json() as any;
|
|
2850
|
+
expect(body.error).toBe("missing_attachment_id");
|
|
2851
|
+
delete process.env.ASSETS_DIR;
|
|
2852
|
+
});
|
|
2853
|
+
|
|
2854
|
+
test("404 attachment_missing when the attachment row no longer exists", async () => {
|
|
2855
|
+
const owner = await store.createNote("voice", { id: "src-stale" });
|
|
2856
|
+
await store.createNote("", {
|
|
2857
|
+
id: "transcript-stale",
|
|
2858
|
+
path: "memo/stale.webm.transcript",
|
|
2859
|
+
metadata: {
|
|
2860
|
+
transcript_of: "memo/stale.webm",
|
|
2861
|
+
transcript_attachment_id: "deleted-attachment-id",
|
|
2862
|
+
transcript_status: "failed",
|
|
2863
|
+
transcript_error: "missing_provider",
|
|
2864
|
+
},
|
|
2865
|
+
});
|
|
2866
|
+
const res = await handleNotes(
|
|
2867
|
+
mkReq("POST", "/notes/transcript-stale/retry-transcription"),
|
|
2868
|
+
store,
|
|
2869
|
+
"/transcript-stale/retry-transcription",
|
|
2870
|
+
"default",
|
|
2871
|
+
);
|
|
2872
|
+
expect(res.status).toBe(404);
|
|
2873
|
+
const body = await res.json() as any;
|
|
2874
|
+
expect(body.error).toBe("attachment_missing");
|
|
2875
|
+
});
|
|
2876
|
+
|
|
2877
|
+
test("405 on GET (must POST)", async () => {
|
|
2878
|
+
await seedFailedTranscript({
|
|
2879
|
+
transcriptId: "transcript-405",
|
|
2880
|
+
noteId: "src-405",
|
|
2881
|
+
audioPath: "memo/405.webm",
|
|
2882
|
+
});
|
|
2883
|
+
const res = await handleNotes(
|
|
2884
|
+
mkReq("GET", "/notes/transcript-405/retry-transcription"),
|
|
2885
|
+
store,
|
|
2886
|
+
"/transcript-405/retry-transcription",
|
|
2887
|
+
"default",
|
|
2888
|
+
);
|
|
2889
|
+
expect(res.status).toBe(405);
|
|
2890
|
+
delete process.env.ASSETS_DIR;
|
|
2891
|
+
});
|
|
2892
|
+
});
|
|
2546
2893
|
});
|
|
2547
2894
|
|
|
2548
2895
|
describe("HTTP GET /notes?format=graph", async () => {
|