@openparachute/vault 0.7.3-rc.10 → 0.7.3-rc.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/src/attachment/bytes-provider.ts +65 -0
- package/core/src/content-range.test.ts +127 -0
- package/core/src/content-range.ts +100 -0
- package/core/src/mcp.ts +331 -1
- package/core/src/vault-projection.ts +17 -10
- package/package.json +1 -1
- package/src/attachment-bytes.ts +68 -0
- package/src/attachment-tickets.test.ts +126 -1
- package/src/attachment-tickets.ts +77 -1
- package/src/mcp-http.ts +20 -3
- package/src/mcp-tools.ts +15 -3
- package/src/read-attachment.test.ts +436 -0
- package/src/routes.ts +80 -4
- package/src/server.ts +7 -0
- package/src/storage.test.ts +200 -1
- package/src/transcription-worker.test.ts +151 -0
- package/src/transcription-worker.ts +113 -52
- package/src/vault.test.ts +26 -13
package/src/routes.ts
CHANGED
|
@@ -143,7 +143,7 @@ import {
|
|
|
143
143
|
type ExpandMode,
|
|
144
144
|
} from "../core/src/expand.ts";
|
|
145
145
|
import { join, extname, normalize } from "path";
|
|
146
|
-
import { existsSync, mkdirSync,
|
|
146
|
+
import { existsSync, mkdirSync, statSync, unlinkSync, writeFileSync } from "fs";
|
|
147
147
|
import { assetsDir, readGlobalConfig, readVaultConfig } from "./config.ts";
|
|
148
148
|
import { shouldAutoTranscribe } from "./auto-transcribe.ts";
|
|
149
149
|
// usage.ts imports `assetsDir` from config.ts (neutral ground), so this import
|
|
@@ -4453,6 +4453,56 @@ export const MAX_REQUEST_BODY_BYTES = MAX_UPLOAD_BYTES + 20 * 1024 * 1024; // 12
|
|
|
4453
4453
|
const BLOCKED_EXTENSIONS = BLOCKED_ATTACHMENT_EXTENSIONS;
|
|
4454
4454
|
const MIME_TYPES = ATTACHMENT_MIME_TYPES;
|
|
4455
4455
|
|
|
4456
|
+
/**
|
|
4457
|
+
* Parse a single-range `Range: bytes=a-b` header (RFC 7233 §2.1 — single
|
|
4458
|
+
* range only, attachments-for-agents design D9, the REST twin of MCP's
|
|
4459
|
+
* `content_offset`). Returns `null` — this function's own contract is
|
|
4460
|
+
* "serve the full response, unranged" — for a missing header, a
|
|
4461
|
+
* MALFORMED value, an unrecognized unit, a multi-range list
|
|
4462
|
+
* (`bytes=0-10,20-30` — ignored, not an error, per D9), or a range this
|
|
4463
|
+
* file can't satisfy (a `start` past EOF). `start`/`end` are both
|
|
4464
|
+
* INCLUSIVE byte offsets; `end` is clamped to `total - 1` when the
|
|
4465
|
+
* request left it open (`bytes=500-`) or asked past EOF.
|
|
4466
|
+
*
|
|
4467
|
+
* IMPORTANT — this `null` contract is NOT the last word for an
|
|
4468
|
+
* unsatisfiable (syntactically-valid but out-of-bounds) range on a real
|
|
4469
|
+
* `Bun.serve()` deployment. Live-verified against an actual socket (not
|
|
4470
|
+
* the in-process `handleStorage()` call the test suite uses): when the
|
|
4471
|
+
* response body is a `Bun.file()` — which `handleStorage`'s full-response
|
|
4472
|
+
* branch below always hands back — Bun's OWN runtime transparently
|
|
4473
|
+
* reinterprets the incoming request's `Range` header a second time and,
|
|
4474
|
+
* for an out-of-bounds range, overrides our 200 with a native
|
|
4475
|
+
* **416 Range Not Satisfiable**, regardless of what this function or
|
|
4476
|
+
* `handleStorage` returned. That's RFC 7233-correct and is being KEPT,
|
|
4477
|
+
* not fought — so in practice, `null` from an out-of-bounds `start`
|
|
4478
|
+
* still results in a 200 from `handleStorage`'s own logic, but the byte
|
|
4479
|
+
* that actually reaches a real client for that specific case is a 416
|
|
4480
|
+
* courtesy of Bun itself. MALFORMED and multi-range headers are NOT
|
|
4481
|
+
* range-shaped at all, so Bun's native layer doesn't touch them — those
|
|
4482
|
+
* two cases genuinely serve the full 200, in-process harness and real
|
|
4483
|
+
* socket alike.
|
|
4484
|
+
*/
|
|
4485
|
+
export function parseByteRangeHeader(header: string | null, total: number): { start: number; end: number } | null {
|
|
4486
|
+
if (!header || total <= 0) return null;
|
|
4487
|
+
const match = header.match(/^bytes=(\d*)-(\d*)$/);
|
|
4488
|
+
if (!match) return null; // malformed, unrecognized unit, or a multi-range list
|
|
4489
|
+
const [, startRaw, endRaw] = match;
|
|
4490
|
+
if (startRaw === "" && endRaw === "") return null;
|
|
4491
|
+
|
|
4492
|
+
if (startRaw === "") {
|
|
4493
|
+
// Suffix range: last N bytes (`bytes=-500`).
|
|
4494
|
+
const suffixLength = Number(endRaw);
|
|
4495
|
+
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return null;
|
|
4496
|
+
return { start: Math.max(0, total - suffixLength), end: total - 1 };
|
|
4497
|
+
}
|
|
4498
|
+
|
|
4499
|
+
const start = Number(startRaw);
|
|
4500
|
+
if (!Number.isSafeInteger(start) || start < 0 || start >= total) return null;
|
|
4501
|
+
const end = endRaw === "" ? total - 1 : Math.min(Number(endRaw), total - 1);
|
|
4502
|
+
if (!Number.isSafeInteger(end) || end < start) return null;
|
|
4503
|
+
return { start, end };
|
|
4504
|
+
}
|
|
4505
|
+
|
|
4456
4506
|
export async function handleStorage(
|
|
4457
4507
|
req: Request,
|
|
4458
4508
|
path: string,
|
|
@@ -4615,12 +4665,38 @@ export async function handleStorage(
|
|
|
4615
4665
|
const stat = statSync(filePath);
|
|
4616
4666
|
const ext = extname(filePath).toLowerCase();
|
|
4617
4667
|
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
4618
|
-
const
|
|
4668
|
+
const total = stat.size;
|
|
4669
|
+
|
|
4670
|
+
// vault attachments-for-agents design (D9) — the REST twin of MCP's
|
|
4671
|
+
// `content_offset`. `Bun.file(filePath)` resolves lazily: `.slice()`
|
|
4672
|
+
// creates a bounded view and only the requested bytes are actually read
|
|
4673
|
+
// on `.arrayBuffer()`/streaming, replacing the prior whole-file
|
|
4674
|
+
// `readFileSync` (a standing memory smell on a large attachment, e.g. a
|
|
4675
|
+
// 90 MB video) on BOTH the ranged and full-file paths below.
|
|
4676
|
+
const bunFile = Bun.file(filePath);
|
|
4677
|
+
const range = parseByteRangeHeader(req.headers.get("range"), total);
|
|
4678
|
+
|
|
4679
|
+
if (range) {
|
|
4680
|
+
const { start, end } = range; // inclusive
|
|
4681
|
+
return new Response(bunFile.slice(start, end + 1), {
|
|
4682
|
+
status: 206,
|
|
4683
|
+
headers: {
|
|
4684
|
+
"Content-Type": contentType,
|
|
4685
|
+
"Content-Length": String(end - start + 1),
|
|
4686
|
+
"Content-Range": `bytes ${start}-${end}/${total}`,
|
|
4687
|
+
"Accept-Ranges": "bytes",
|
|
4688
|
+
// Defense-in-depth: never let a browser MIME-sniff a stored asset
|
|
4689
|
+
// into an active type — see the full-response branch below.
|
|
4690
|
+
"X-Content-Type-Options": "nosniff",
|
|
4691
|
+
},
|
|
4692
|
+
});
|
|
4693
|
+
}
|
|
4619
4694
|
|
|
4620
|
-
return new Response(
|
|
4695
|
+
return new Response(bunFile, {
|
|
4621
4696
|
headers: {
|
|
4622
4697
|
"Content-Type": contentType,
|
|
4623
|
-
"Content-Length": String(
|
|
4698
|
+
"Content-Length": String(total),
|
|
4699
|
+
"Accept-Ranges": "bytes",
|
|
4624
4700
|
// Defense-in-depth: never let a browser MIME-sniff a stored asset into
|
|
4625
4701
|
// an active type (e.g. an octet-stream body sniffed as text/html).
|
|
4626
4702
|
// Combined with the upload blocklist (no .svg/.html) this closes the
|
package/src/server.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { migrateVaultKeys } from "./token-store.ts";
|
|
|
21
21
|
import { resolveFirstBootVaultName, reservedNameSquatWarnings } from "./vault-name.ts";
|
|
22
22
|
import { getVaultStore, getVaultNameForStore, getSharedEmbeddingProvider } from "./vault-store.ts";
|
|
23
23
|
import { EmbeddingWorker, registerEmbeddingHook } from "./embedding-worker.ts";
|
|
24
|
+
import { startAttachmentTicketSweep, stopAttachmentTicketSweep } from "./attachment-tickets.ts";
|
|
24
25
|
import { seedOnboardingNotesBestEffort } from "./onboarding-seed.ts";
|
|
25
26
|
import { defaultHookRegistry } from "../core/src/hooks.ts";
|
|
26
27
|
import { registerTriggers } from "./triggers.ts";
|
|
@@ -237,6 +238,11 @@ const embeddingWorker = new EmbeddingWorker({
|
|
|
237
238
|
registerEmbeddingHook(defaultHookRegistry, embeddingWorker, (store) => getVaultNameForStore(store as never));
|
|
238
239
|
embeddingWorker.start();
|
|
239
240
|
|
|
241
|
+
// Attachment-ticket sweep (vault#612) — drops expired-unspent tickets from
|
|
242
|
+
// the in-process store so an abandoned mint (an agent that never curls)
|
|
243
|
+
// doesn't sit in memory forever. See src/attachment-tickets.ts.
|
|
244
|
+
startAttachmentTicketSweep();
|
|
245
|
+
|
|
240
246
|
if (process.env.VAULT_AUTH_TOKEN?.trim()) {
|
|
241
247
|
console.log("[auth] VAULT_AUTH_TOKEN set — server-wide operator bearer active");
|
|
242
248
|
}
|
|
@@ -619,6 +625,7 @@ async function shutdown(signal: string): Promise<void> {
|
|
|
619
625
|
// Then drain hooks + stop the transcription/embedding workers in
|
|
620
626
|
// parallel.
|
|
621
627
|
embeddingWorker.stop();
|
|
628
|
+
stopAttachmentTicketSweep();
|
|
622
629
|
await Promise.all([
|
|
623
630
|
defaultHookRegistry.drain(),
|
|
624
631
|
transcriptionWorker?.stop() ?? Promise.resolve(),
|
package/src/storage.test.ts
CHANGED
|
@@ -30,7 +30,7 @@ const testDir = join(
|
|
|
30
30
|
process.env.PARACHUTE_HOME = testDir;
|
|
31
31
|
process.env.ASSETS_DIR = join(testDir, "assets");
|
|
32
32
|
|
|
33
|
-
const { handleStorage, MAX_UPLOAD_BYTES, MAX_REQUEST_BODY_BYTES } = await import("./routes.ts");
|
|
33
|
+
const { handleStorage, MAX_UPLOAD_BYTES, MAX_REQUEST_BODY_BYTES, parseByteRangeHeader } = await import("./routes.ts");
|
|
34
34
|
const { expandTokenTagScope } = await import("./tag-scope.ts");
|
|
35
35
|
|
|
36
36
|
// The upload-allowlist tests never touch the store (POST /upload writes to
|
|
@@ -564,3 +564,202 @@ describe("storage POST parity (finding D)", () => {
|
|
|
564
564
|
expect(res.status).toBe(404);
|
|
565
565
|
});
|
|
566
566
|
});
|
|
567
|
+
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
// REST Range (206) — vault attachments-for-agents design D9, the REST twin
|
|
570
|
+
// of MCP's content_offset/content_length.
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
|
|
573
|
+
describe("parseByteRangeHeader", () => {
|
|
574
|
+
test("no header → null (full response)", () => {
|
|
575
|
+
expect(parseByteRangeHeader(null, 100)).toBeNull();
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("bytes=0-99 on a 100-byte file → the whole file as one satisfiable range", () => {
|
|
579
|
+
expect(parseByteRangeHeader("bytes=0-99", 100)).toEqual({ start: 0, end: 99 });
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("bytes=10-19 → an inclusive mid-file window", () => {
|
|
583
|
+
expect(parseByteRangeHeader("bytes=10-19", 100)).toEqual({ start: 10, end: 19 });
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test("bytes=90- (open-ended) → reads to EOF", () => {
|
|
587
|
+
expect(parseByteRangeHeader("bytes=90-", 100)).toEqual({ start: 90, end: 99 });
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test("bytes=-10 (suffix range) → the last 10 bytes", () => {
|
|
591
|
+
expect(parseByteRangeHeader("bytes=-10", 100)).toEqual({ start: 90, end: 99 });
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("bytes=-1000 (suffix longer than the file) → clamps to the whole file", () => {
|
|
595
|
+
expect(parseByteRangeHeader("bytes=-1000", 100)).toEqual({ start: 0, end: 99 });
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("bytes=50-1000 (end past EOF) → clamps end to the last byte", () => {
|
|
599
|
+
expect(parseByteRangeHeader("bytes=50-1000", 100)).toEqual({ start: 50, end: 99 });
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
test("start past EOF → null (this function's own contract; a real Bun.serve() socket overrides with a native 416 — see doc comment)", () => {
|
|
603
|
+
expect(parseByteRangeHeader("bytes=100-200", 100)).toBeNull();
|
|
604
|
+
expect(parseByteRangeHeader("bytes=1000-", 100)).toBeNull();
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test("start > end → null (malformed)", () => {
|
|
608
|
+
expect(parseByteRangeHeader("bytes=50-10", 100)).toBeNull();
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test("bytes=- (both empty) → null", () => {
|
|
612
|
+
expect(parseByteRangeHeader("bytes=-", 100)).toBeNull();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("a multi-range list (bytes=0-10,20-30) → null (ignored per D9, not an error)", () => {
|
|
616
|
+
expect(parseByteRangeHeader("bytes=0-10,20-30", 100)).toBeNull();
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("an unrecognized unit → null", () => {
|
|
620
|
+
expect(parseByteRangeHeader("items=0-10", 100)).toBeNull();
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test("garbage value → null, not a throw", () => {
|
|
624
|
+
expect(parseByteRangeHeader("bytes=abc-def", 100)).toBeNull();
|
|
625
|
+
expect(parseByteRangeHeader("nonsense", 100)).toBeNull();
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test("a zero-byte file → null regardless of header (nothing to range over)", () => {
|
|
629
|
+
expect(parseByteRangeHeader("bytes=0-0", 0)).toBeNull();
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
describe("storage GET Range support (vault attachments-for-agents design D9)", () => {
|
|
634
|
+
const VAULT = "range-vault";
|
|
635
|
+
// 26 bytes, byte-identical to its own index — content[i] === i (0x00..0x19)
|
|
636
|
+
// — so any slice's bytes can be asserted exactly by index, not just length.
|
|
637
|
+
const CONTENT = Buffer.from(Array.from({ length: 26 }, (_, i) => i));
|
|
638
|
+
|
|
639
|
+
async function setup(): Promise<{ store: SqliteStore; assets: string; relPath: string }> {
|
|
640
|
+
const store = freshStore();
|
|
641
|
+
const assets = join(testDir, "assets", VAULT, "data");
|
|
642
|
+
mkdirSync(join(assets, "2026-05-28"), { recursive: true });
|
|
643
|
+
process.env.ASSETS_DIR = assets;
|
|
644
|
+
|
|
645
|
+
const relPath = "2026-05-28/ranged.bin";
|
|
646
|
+
writeFileSync(join(assets, relPath), CONTENT);
|
|
647
|
+
const note = await store.createNote("range note", { tags: ["misc"] });
|
|
648
|
+
await store.addAttachment(note.id, relPath, "application/octet-stream");
|
|
649
|
+
|
|
650
|
+
return { store, assets, relPath };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function getReq(reqPath: string, range?: string): Request {
|
|
654
|
+
const headers: Record<string, string> = {};
|
|
655
|
+
if (range !== undefined) headers.range = range;
|
|
656
|
+
return new Request(`http://localhost:1940/storage/${reqPath}`, { method: "GET", headers });
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
test("no Range header → 200, full body, Accept-Ranges advertised", async () => {
|
|
660
|
+
const { store, relPath } = await setup();
|
|
661
|
+
const res = await handleStorage(getReq(relPath), `/${relPath}`, VAULT, store);
|
|
662
|
+
expect(res.status).toBe(200);
|
|
663
|
+
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
|
664
|
+
expect(res.headers.get("Content-Length")).toBe("26");
|
|
665
|
+
expect(res.headers.has("Content-Range")).toBe(false);
|
|
666
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
667
|
+
expect(body.equals(CONTENT)).toBe(true);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
test("Range: bytes=0-9 → 206, first 10 bytes, correct Content-Range", async () => {
|
|
671
|
+
const { store, relPath } = await setup();
|
|
672
|
+
const res = await handleStorage(getReq(relPath, "bytes=0-9"), `/${relPath}`, VAULT, store);
|
|
673
|
+
expect(res.status).toBe(206);
|
|
674
|
+
expect(res.headers.get("Content-Range")).toBe("bytes 0-9/26");
|
|
675
|
+
expect(res.headers.get("Content-Length")).toBe("10");
|
|
676
|
+
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
|
677
|
+
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
678
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
679
|
+
expect(body.equals(CONTENT.subarray(0, 10))).toBe(true);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test("Range: bytes=16-25 (to EOF) → 206, correct tail bytes", async () => {
|
|
683
|
+
const { store, relPath } = await setup();
|
|
684
|
+
const res = await handleStorage(getReq(relPath, "bytes=16-25"), `/${relPath}`, VAULT, store);
|
|
685
|
+
expect(res.status).toBe(206);
|
|
686
|
+
expect(res.headers.get("Content-Range")).toBe("bytes 16-25/26");
|
|
687
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
688
|
+
expect(body.equals(CONTENT.subarray(16, 26))).toBe(true);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test("Range: bytes=-5 (suffix) → 206, last 5 bytes", async () => {
|
|
692
|
+
const { store, relPath } = await setup();
|
|
693
|
+
const res = await handleStorage(getReq(relPath, "bytes=-5"), `/${relPath}`, VAULT, store);
|
|
694
|
+
expect(res.status).toBe(206);
|
|
695
|
+
expect(res.headers.get("Content-Range")).toBe("bytes 21-25/26");
|
|
696
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
697
|
+
expect(body.equals(CONTENT.subarray(21, 26))).toBe(true);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
test("paging the whole file via sequential ranges reassembles byte-identical content", async () => {
|
|
701
|
+
const { store, relPath } = await setup();
|
|
702
|
+
const chunkSize = 7; // doesn't divide 26 evenly — exercises a short final chunk
|
|
703
|
+
const chunks: Buffer[] = [];
|
|
704
|
+
for (let start = 0; start < 26; start += chunkSize) {
|
|
705
|
+
const end = Math.min(start + chunkSize - 1, 25);
|
|
706
|
+
const res = await handleStorage(getReq(relPath, `bytes=${start}-${end}`), `/${relPath}`, VAULT, store);
|
|
707
|
+
expect(res.status).toBe(206);
|
|
708
|
+
chunks.push(Buffer.from(await res.arrayBuffer()));
|
|
709
|
+
}
|
|
710
|
+
expect(Buffer.concat(chunks).equals(CONTENT)).toBe(true);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test("a malformed Range header → 200 full response, not an error (D9: ignore, don't fail)", async () => {
|
|
714
|
+
const { store, relPath } = await setup();
|
|
715
|
+
const res = await handleStorage(getReq(relPath, "not-a-range"), `/${relPath}`, VAULT, store);
|
|
716
|
+
expect(res.status).toBe(200);
|
|
717
|
+
expect(res.headers.has("Content-Range")).toBe(false);
|
|
718
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
719
|
+
expect(body.equals(CONTENT)).toBe(true);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test("a multi-range Range header → 200 full response (multipart ranges unsupported, ignored per D9)", async () => {
|
|
723
|
+
const { store, relPath } = await setup();
|
|
724
|
+
const res = await handleStorage(getReq(relPath, "bytes=0-5,10-15"), `/${relPath}`, VAULT, store);
|
|
725
|
+
expect(res.status).toBe(200);
|
|
726
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
727
|
+
expect(body.equals(CONTENT)).toBe(true);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
test("an unsatisfiable Range (start past EOF) → handleStorage's OWN logic returns 200 (parseByteRangeHeader's null contract)", async () => {
|
|
731
|
+
// IMPORTANT — this is NOT the whole live story. `handleStorage` is
|
|
732
|
+
// called here directly, in-process, so this only exercises OUR
|
|
733
|
+
// fallback logic (parseByteRangeHeader → null → the unranged 200
|
|
734
|
+
// branch). Live-verified against a real `Bun.serve()` socket (not
|
|
735
|
+
// this harness): Bun's own runtime independently reinterprets the
|
|
736
|
+
// incoming `Range` header on a `Bun.file()`-backed response body and
|
|
737
|
+
// overrides an out-of-bounds range with a native
|
|
738
|
+
// **416 Range Not Satisfiable** — RFC 7233-correct, and kept, not
|
|
739
|
+
// fought. This in-process harness has no socket for Bun's native
|
|
740
|
+
// layer to intercept, so it can only ever observe the 200 our own
|
|
741
|
+
// code returns — see `parseByteRangeHeader`'s doc comment
|
|
742
|
+
// (`src/routes.ts`) for the full explanation.
|
|
743
|
+
const { store, relPath } = await setup();
|
|
744
|
+
const res = await handleStorage(getReq(relPath, "bytes=1000-2000"), `/${relPath}`, VAULT, store);
|
|
745
|
+
expect(res.status).toBe(200);
|
|
746
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
747
|
+
expect(body.equals(CONTENT)).toBe(true);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
test("Range is still honored for a tag-scoped in-scope request (206)", async () => {
|
|
751
|
+
const { store, relPath } = await setup();
|
|
752
|
+
const ctx = await tagScopeCtx(store, ["misc"]);
|
|
753
|
+
const res = await handleStorage(getReq(relPath, "bytes=0-3"), `/${relPath}`, VAULT, store, ctx);
|
|
754
|
+
expect(res.status).toBe(206);
|
|
755
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
756
|
+
expect(body.equals(CONTENT.subarray(0, 4))).toBe(true);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test("Range on an out-of-scope attachment still 404s (same confinement guard, byte-identical to the non-ranged path)", async () => {
|
|
760
|
+
const { store, relPath } = await setup();
|
|
761
|
+
const ctx = await tagScopeCtx(store, ["totally-unrelated-tag"]);
|
|
762
|
+
const res = await handleStorage(getReq(relPath, "bytes=0-3"), `/${relPath}`, VAULT, store, ctx);
|
|
763
|
+
expect(res.status).toBe(404);
|
|
764
|
+
});
|
|
765
|
+
});
|
|
@@ -1627,3 +1627,154 @@ describe("transcription worker — legacy in-body memo content safety (finding F
|
|
|
1627
1627
|
expect(note!.content).toContain("retry text with $& and $0 and $$ literal");
|
|
1628
1628
|
});
|
|
1629
1629
|
});
|
|
1630
|
+
|
|
1631
|
+
// ---------------------------------------------------------------------------
|
|
1632
|
+
// voice W2 — segmented recordings. The app slices a long recording into
|
|
1633
|
+
// ~10-min segments, each linked as its own attachment on ONE note carrying a
|
|
1634
|
+
// client-set `segment_index` (0-based). The legacy in-body path targets that
|
|
1635
|
+
// part's markers — `_Transcript pending (part N)._` / `_Transcription
|
|
1636
|
+
// unavailable (part N)._`, N = segment_index + 1 — so each transcript lands in
|
|
1637
|
+
// its own pre-allocated slot regardless of completion order. Marker strings
|
|
1638
|
+
// are a BYTE-EXACT cross-door + cross-repo contract (the cloud worker ships
|
|
1639
|
+
// the identical text), so these assertions pin the exact bytes.
|
|
1640
|
+
// ---------------------------------------------------------------------------
|
|
1641
|
+
|
|
1642
|
+
describe("transcription worker — segmented recordings (voice W2)", () => {
|
|
1643
|
+
test("three parts completing OUT OF ORDER (2 ok, 0 fails, 1 ok) each land in their own slot", async () => {
|
|
1644
|
+
// One note, three pre-allocated part slots + the shared stub opt-in.
|
|
1645
|
+
const body =
|
|
1646
|
+
"# 🎙️ Voice memo\n\n" +
|
|
1647
|
+
"_Transcript pending (part 1)._\n\n" +
|
|
1648
|
+
"_Transcript pending (part 2)._\n\n" +
|
|
1649
|
+
"_Transcript pending (part 3)._\n";
|
|
1650
|
+
await store.createNote(body, { id: "seg-note", metadata: { transcribe_stub: true } });
|
|
1651
|
+
|
|
1652
|
+
seedAudio("memos/seg0.webm");
|
|
1653
|
+
seedAudio("memos/seg1.webm");
|
|
1654
|
+
seedAudio("memos/seg2.webm");
|
|
1655
|
+
const seg0 = await store.addAttachment("seg-note", "memos/seg0.webm", "audio/webm", {
|
|
1656
|
+
transcribe_status: "pending",
|
|
1657
|
+
segment_index: 0,
|
|
1658
|
+
});
|
|
1659
|
+
const seg1 = await store.addAttachment("seg-note", "memos/seg1.webm", "audio/webm", {
|
|
1660
|
+
transcribe_status: "pending",
|
|
1661
|
+
segment_index: 1,
|
|
1662
|
+
});
|
|
1663
|
+
const seg2 = await store.addAttachment("seg-note", "memos/seg2.webm", "audio/webm", {
|
|
1664
|
+
transcribe_status: "pending",
|
|
1665
|
+
segment_index: 2,
|
|
1666
|
+
});
|
|
1667
|
+
|
|
1668
|
+
// Complete part 3 (segment_index 2) FIRST — success.
|
|
1669
|
+
const w2 = makeWorker({ fetchImpl: mkFetchMock([{ text: "part three text" }]) });
|
|
1670
|
+
try { await w2.kick("default", seg2); } finally { await w2.stop(); }
|
|
1671
|
+
|
|
1672
|
+
// Then part 1 (segment_index 0) — terminal failure (maxAttempts=1).
|
|
1673
|
+
const w0 = makeWorker({
|
|
1674
|
+
fetchImpl: mkFetchMock([{ error: "scribe down", status: 500 }]),
|
|
1675
|
+
maxAttempts: 1,
|
|
1676
|
+
});
|
|
1677
|
+
try { await w0.kick("default", seg0); } finally { await w0.stop(); }
|
|
1678
|
+
|
|
1679
|
+
// Finally part 2 (segment_index 1) — success.
|
|
1680
|
+
const w1 = makeWorker({ fetchImpl: mkFetchMock([{ text: "part two text" }]) });
|
|
1681
|
+
try { await w1.kick("default", seg1); } finally { await w1.stop(); }
|
|
1682
|
+
|
|
1683
|
+
const note = await store.getNote("seg-note");
|
|
1684
|
+
// Each part landed in its own slot: part 1 → failure marker, part 2/3 →
|
|
1685
|
+
// their transcripts, despite completing 3 → 1 → 2.
|
|
1686
|
+
expect(note!.content).toBe(
|
|
1687
|
+
"# 🎙️ Voice memo\n\n" +
|
|
1688
|
+
"_Transcription unavailable (part 1)._\n\n" +
|
|
1689
|
+
"part two text\n\n" +
|
|
1690
|
+
"part three text\n",
|
|
1691
|
+
);
|
|
1692
|
+
// Shared stub SURVIVES — sibling parts each needed the gate open; a
|
|
1693
|
+
// per-part clear (the un-segmented behavior) would have blocked parts 2 & 3.
|
|
1694
|
+
expect((note!.metadata as any)?.transcribe_stub).toBe(true);
|
|
1695
|
+
|
|
1696
|
+
// Attachment rows reflect their individual outcomes.
|
|
1697
|
+
const atts = await store.getAttachments("seg-note");
|
|
1698
|
+
const byIndex = Object.fromEntries(atts.map((a) => [a.metadata?.segment_index, a]));
|
|
1699
|
+
expect(byIndex[0]!.metadata?.transcribe_status).toBe("failed");
|
|
1700
|
+
expect(byIndex[1]!.metadata?.transcribe_status).toBe("done");
|
|
1701
|
+
expect(byIndex[1]!.metadata?.transcript).toBe("part two text");
|
|
1702
|
+
expect(byIndex[2]!.metadata?.transcribe_status).toBe("done");
|
|
1703
|
+
expect(byIndex[2]!.metadata?.transcript).toBe("part three text");
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
test("un-segmented attachment → BARE markers, one-shot stub cleared (regression pin)", async () => {
|
|
1707
|
+
// No `segment_index` → byte-for-byte the pre-W2 behavior: replace the bare
|
|
1708
|
+
// placeholder, clear the stub. A stray `(part N)` marker in the body is
|
|
1709
|
+
// left untouched (the bare path never targets part markers).
|
|
1710
|
+
await store.createNote(
|
|
1711
|
+
"# 🎙️ Voice memo\n\n_Transcript pending._\n\n_Transcript pending (part 2)._\n",
|
|
1712
|
+
{ id: "unseg-note", metadata: { transcribe_stub: true } },
|
|
1713
|
+
);
|
|
1714
|
+
seedAudio("memos/unseg.webm");
|
|
1715
|
+
const att = await store.addAttachment("unseg-note", "memos/unseg.webm", "audio/webm", {
|
|
1716
|
+
transcribe_status: "pending",
|
|
1717
|
+
});
|
|
1718
|
+
|
|
1719
|
+
const worker = makeWorker({ fetchImpl: mkFetchMock([{ text: "bare transcript" }]) });
|
|
1720
|
+
try { await worker.kick("default", att); } finally { await worker.stop(); }
|
|
1721
|
+
|
|
1722
|
+
const note = await store.getNote("unseg-note");
|
|
1723
|
+
// Bare marker replaced; the part-2 marker is NOT touched by the bare path.
|
|
1724
|
+
expect(note!.content).toBe(
|
|
1725
|
+
"# 🎙️ Voice memo\n\nbare transcript\n\n_Transcript pending (part 2)._\n",
|
|
1726
|
+
);
|
|
1727
|
+
// One-shot stub cleared, exactly as today.
|
|
1728
|
+
expect((note!.metadata as any)?.transcribe_stub).toBeUndefined();
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
test("malformed segment_index falls back to bare markers (contract: integer ≥ 0)", async () => {
|
|
1732
|
+
// A non-integer / negative `segment_index` must NOT fabricate a `(part N)`
|
|
1733
|
+
// marker — it degrades to the bare path (fully backward compatible).
|
|
1734
|
+
await store.createNote(
|
|
1735
|
+
"# 🎙️ Voice memo\n\n_Transcript pending._\n",
|
|
1736
|
+
{ id: "seg-bad", metadata: { transcribe_stub: true } },
|
|
1737
|
+
);
|
|
1738
|
+
seedAudio("memos/segbad.webm");
|
|
1739
|
+
const att = await store.addAttachment("seg-bad", "memos/segbad.webm", "audio/webm", {
|
|
1740
|
+
transcribe_status: "pending",
|
|
1741
|
+
segment_index: -1, // invalid → bare path
|
|
1742
|
+
});
|
|
1743
|
+
|
|
1744
|
+
const worker = makeWorker({ fetchImpl: mkFetchMock([{ text: "fallback transcript" }]) });
|
|
1745
|
+
try { await worker.kick("default", att); } finally { await worker.stop(); }
|
|
1746
|
+
|
|
1747
|
+
const note = await store.getNote("seg-bad");
|
|
1748
|
+
expect(note!.content).toBe("# 🎙️ Voice memo\n\nfallback transcript\n");
|
|
1749
|
+
expect((note!.metadata as any)?.transcribe_stub).toBeUndefined();
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
test("segmented part whose marker was edited away → transcript appended (graceful fallback)", async () => {
|
|
1753
|
+
// The user rewrote part 2's slot by hand, removing its pending marker.
|
|
1754
|
+
// Mirror the un-segmented replace-or-append policy scoped to the part:
|
|
1755
|
+
// with neither of part 2's markers present, append the transcript
|
|
1756
|
+
// (no `(part N)` prefix) rather than destroying the user's edit.
|
|
1757
|
+
const editedBody =
|
|
1758
|
+
"# 🎙️ Voice memo\n\n" +
|
|
1759
|
+
"_Transcript pending (part 1)._\n\n" +
|
|
1760
|
+
"User rewrote part two by hand.\n";
|
|
1761
|
+
await store.createNote(editedBody, { id: "seg-edit", metadata: { transcribe_stub: true } });
|
|
1762
|
+
seedAudio("memos/segedit.webm");
|
|
1763
|
+
const seg1 = await store.addAttachment("seg-edit", "memos/segedit.webm", "audio/webm", {
|
|
1764
|
+
transcribe_status: "pending",
|
|
1765
|
+
segment_index: 1, // part 2
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1768
|
+
const worker = makeWorker({ fetchImpl: mkFetchMock([{ text: "the real part two" }]) });
|
|
1769
|
+
try { await worker.kick("default", seg1); } finally { await worker.stop(); }
|
|
1770
|
+
|
|
1771
|
+
const note = await store.getNote("seg-edit");
|
|
1772
|
+
// Part 2's transcript appended; the user's edit + part 1's pending slot
|
|
1773
|
+
// both survive untouched.
|
|
1774
|
+
expect(note!.content).toBe(`${editedBody}\n\nthe real part two`);
|
|
1775
|
+
expect(note!.content).toContain("User rewrote part two by hand.");
|
|
1776
|
+
expect(note!.content).toContain("_Transcript pending (part 1)._");
|
|
1777
|
+
// Segmented → shared stub preserved (part 1 still needs the gate open).
|
|
1778
|
+
expect((note!.metadata as any)?.transcribe_stub).toBe(true);
|
|
1779
|
+
});
|
|
1780
|
+
});
|