@openparachute/vault 0.7.3 → 0.7.4-rc.2

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/src/routing.ts CHANGED
@@ -116,6 +116,7 @@ import {
116
116
  handleMirrorPut,
117
117
  handleMirrorRunNow,
118
118
  } from "./mirror-routes.ts";
119
+ import { handleEmbeddingsGet, handleEmbeddingsPut } from "./embeddings-routes.ts";
119
120
  import { getMirrorManager } from "./mirror-registry.ts";
120
121
  import { buildUsageReport } from "./usage.ts";
121
122
  import { handleTicketSpend } from "./attachment-tickets.ts";
@@ -687,6 +688,33 @@ export async function route(
687
688
  return Response.json(buildUsageReport(vaultName, stats, { fresh }));
688
689
  }
689
690
 
691
+ // /.parachute/embeddings — Admin-gated read+write of the semantic-search
692
+ // (embeddings) opt-in toggle. The 0.7.3 fast-follow: gives the admin SPA a
693
+ // real toggle over the persisted `embeddings_enabled` config.yaml setting so
694
+ // an operator flips semantic search on from the UI instead of hand-editing
695
+ // config or setting an env var. Host-global setting (affects every vault),
696
+ // reached through the same per-vault admin surface as the other settings
697
+ // pages. Activation is restart-to-apply — the endpoint persists the setting
698
+ // and reports `restart_required`; see embeddings-routes.ts for why the boot-
699
+ // captured provider isn't hot-reconfigured.
700
+ if (subpath === "/.parachute/embeddings") {
701
+ if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
702
+ return Response.json(
703
+ {
704
+ error: "Forbidden",
705
+ error_type: "insufficient_scope",
706
+ message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
707
+ required_scope: SCOPE_ADMIN,
708
+ granted_scopes: auth.scopes,
709
+ },
710
+ { status: 403 },
711
+ );
712
+ }
713
+ if (req.method === "GET") return handleEmbeddingsGet();
714
+ if (req.method === "PUT") return handleEmbeddingsPut(req);
715
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
716
+ }
717
+
690
718
  // The per-vault `/tokens` REST surface (pvt_* mint/list/revoke) was removed
691
719
  // at 0.5.0 (vault#282 Stage 2 — vault is a pure hub resource-server). Hub
692
720
  // JWTs are minted via hub's registry (`/api/auth/mint-token`); a `/tokens`
@@ -68,6 +68,21 @@ export function getSharedEmbeddingProvider(): EmbeddingProvider | undefined {
68
68
  return sharedEmbeddingProvider;
69
69
  }
70
70
 
71
+ /**
72
+ * Whether semantic search is LIVE in this running process right now — i.e.
73
+ * the boot-resolved shared provider is present. This is the honest
74
+ * "currently active" signal the admin settings surface reports, distinct
75
+ * from the *persisted* `embeddings_enabled` setting: because the provider
76
+ * is memoized at boot (into every open store + the embedding worker),
77
+ * flipping the persisted setting does NOT change this until the next
78
+ * server restart. The admin toggle compares this against the freshly
79
+ * resolved effective state to tell the operator when a restart is pending
80
+ * (see `src/embeddings-routes.ts`).
81
+ */
82
+ export function isSharedEmbeddingProviderActive(): boolean {
83
+ return getSharedEmbeddingProvider() !== undefined;
84
+ }
85
+
71
86
  /** Test-only: force a fresh provider on the next `getSharedEmbeddingProvider()` call. */
72
87
  export function resetSharedEmbeddingProviderForTests(): void {
73
88
  sharedEmbeddingProvider = undefined;
package/src/vault.test.ts CHANGED
@@ -2913,6 +2913,173 @@ describe("HTTP /notes", async () => {
2913
2913
  });
2914
2914
  });
2915
2915
 
2916
+ describe("POST /notes/:id/attachments with segment_index (voice W2)", async () => {
2917
+ test("valid segment_index (integer >= 0) lands on the attachment's metadata", async () => {
2918
+ await store.createNote("# 🎙️ Voice memo\n\n_Transcript pending (part 1)._", { id: "seg1" });
2919
+ const res = await handleNotes(
2920
+ mkReq("POST", "/notes/seg1/attachments", {
2921
+ path: "memos/part-1.webm",
2922
+ mimeType: "audio/webm",
2923
+ transcribe: true,
2924
+ segment_index: 0,
2925
+ }),
2926
+ store,
2927
+ "/seg1/attachments",
2928
+ );
2929
+ expect(res.status).toBe(201);
2930
+ const att = await res.json() as any;
2931
+ expect(att.metadata?.segment_index).toBe(0);
2932
+ expect(att.metadata?.transcribe_status).toBe("pending");
2933
+ });
2934
+
2935
+ test.each([
2936
+ ["negative", -1],
2937
+ ["non-integer", 1.5],
2938
+ ["string", "1"],
2939
+ ])("invalid segment_index (%s) is dropped, not stored, same as cloud's fallback", async (_label, bad) => {
2940
+ await store.createNote("# 🎙️ Voice memo\n\n_Transcript pending._", { id: `seg-bad-${_label}` });
2941
+ const res = await handleNotes(
2942
+ mkReq("POST", `/notes/seg-bad-${_label}/attachments`, {
2943
+ path: "memos/bad.webm",
2944
+ mimeType: "audio/webm",
2945
+ transcribe: true,
2946
+ segment_index: bad,
2947
+ }),
2948
+ store,
2949
+ `/seg-bad-${_label}/attachments`,
2950
+ );
2951
+ // Malformed segment_index is NOT a request error — it silently falls
2952
+ // back to the un-segmented path (mirrors cloud's notes.ts validSegment
2953
+ // check), so linking still succeeds.
2954
+ expect(res.status).toBe(201);
2955
+ const att = await res.json() as any;
2956
+ expect(att.metadata?.segment_index).toBeUndefined();
2957
+ expect(att.metadata?.transcribe_status).toBe("pending");
2958
+ });
2959
+
2960
+ test("absent segment_index leaves the un-segmented path byte-unchanged", async () => {
2961
+ await store.createNote("# 🎙️ Voice memo\n\n_Transcript pending._", { id: "seg-absent" });
2962
+ const res = await handleNotes(
2963
+ mkReq("POST", "/notes/seg-absent/attachments", {
2964
+ path: "memos/bare.webm",
2965
+ mimeType: "audio/webm",
2966
+ transcribe: true,
2967
+ }),
2968
+ store,
2969
+ "/seg-absent/attachments",
2970
+ );
2971
+ expect(res.status).toBe(201);
2972
+ const att = await res.json() as any;
2973
+ expect(att.metadata?.segment_index).toBeUndefined();
2974
+ expect(att.metadata?.transcribe_status).toBe("pending");
2975
+ });
2976
+
2977
+ // ---- The join test, not just the door's half ------------------------
2978
+ //
2979
+ // The three tests above post `segment_index` at TOP LEVEL — the shape
2980
+ // both doors have now agreed on (cloud always read it there; self-host
2981
+ // didn't read it at all until this PR). That's a deliberate contract
2982
+ // choice, not a guess at what any client sends: the app was found
2983
+ // nesting it under `metadata` instead, which is the OTHER half of this
2984
+ // bug and is being fixed separately in sibling PR parachute-app#126
2985
+ // ("segment_index rides top-level on the wire"). Top-level is the one
2986
+ // true shape going forward; nested is a bug in the emitter, not a shape
2987
+ // either door should learn to accept.
2988
+ //
2989
+ // What this test proves: given a top-level `segment_index`, self-host
2990
+ // stores it AND the transcription worker resolves the correct per-part
2991
+ // marker from it — the full loop this door owns.
2992
+ // What it does NOT prove: that any real client actually sends this
2993
+ // shape today. app#126 pins the app's emission; this pins the door's
2994
+ // reception. Proving the two actually join — the app's real request
2995
+ // body landing on a running self-host vault and coming out right —
2996
+ // needs a cross-repo conformance test that doesn't exist yet (filed as
2997
+ // vault#629; that gap is exactly how this bug shipped in the first
2998
+ // place, since cloud's own conformance test was green against a shape
2999
+ // the app never sent).
3000
+ test("end-to-end: top-level segment_index on TWO real attachments resolves each part's marker independently", async () => {
3001
+ const assetsRoot = join(tmpDir, "assets");
3002
+ mkdirSync(join(assetsRoot, "memos"), { recursive: true });
3003
+ writeFileSync(join(assetsRoot, "memos/e2e-0.webm"), Buffer.from([1, 2, 3]));
3004
+ writeFileSync(join(assetsRoot, "memos/e2e-1.webm"), Buffer.from([4, 5, 6]));
3005
+ process.env.ASSETS_DIR = assetsRoot;
3006
+
3007
+ await store.createNote(
3008
+ "# 🎙️ Voice memo\n\n_Transcript pending (part 1)._\n\n_Transcript pending (part 2)._\n",
3009
+ { id: "seg-e2e", metadata: { transcribe_stub: true } },
3010
+ );
3011
+
3012
+ // Link both parts through the REAL REST endpoint, exactly as the
3013
+ // (now-fixed) app will call it: top-level segment_index, not nested.
3014
+ const res0 = await handleNotes(
3015
+ mkReq("POST", "/notes/seg-e2e/attachments", {
3016
+ path: "memos/e2e-0.webm",
3017
+ mimeType: "audio/webm",
3018
+ transcribe: true,
3019
+ segment_index: 0,
3020
+ }),
3021
+ store,
3022
+ "/seg-e2e/attachments",
3023
+ );
3024
+ const res1 = await handleNotes(
3025
+ mkReq("POST", "/notes/seg-e2e/attachments", {
3026
+ path: "memos/e2e-1.webm",
3027
+ mimeType: "audio/webm",
3028
+ transcribe: true,
3029
+ segment_index: 1,
3030
+ }),
3031
+ store,
3032
+ "/seg-e2e/attachments",
3033
+ );
3034
+ expect(res0.status).toBe(201);
3035
+ expect(res1.status).toBe(201);
3036
+ const att0 = await res0.json() as any;
3037
+ const att1 = await res1.json() as any;
3038
+
3039
+ const worker = startTranscriptionWorker({
3040
+ vaultList: () => ["default"],
3041
+ getStore: () => store as unknown as Store,
3042
+ scribeUrl: "http://scribe.test",
3043
+ resolveAssetsDir: () => process.env.ASSETS_DIR!,
3044
+ pollIntervalMs: 10_000_000,
3045
+ maxAttempts: 3,
3046
+ fetchImpl: (async () => new Response(
3047
+ JSON.stringify({ text: "part text" }),
3048
+ { status: 200, headers: { "content-type": "application/json" } },
3049
+ )) as typeof fetch,
3050
+ logger: { error: () => {}, info: () => {} },
3051
+ });
3052
+ try {
3053
+ // Complete part 2 first, then part 1 — out of order, same as the
3054
+ // original bug report.
3055
+ await worker.kick("default", att1);
3056
+ const midway = await store.getNote("seg-e2e");
3057
+ expect(midway!.content).toBe(
3058
+ "# 🎙️ Voice memo\n\n_Transcript pending (part 1)._\n\npart text\n",
3059
+ );
3060
+ // Shared stub SURVIVES — part 1 still needs the gate open. Before
3061
+ // this fix, an unstored segment_index made every part look
3062
+ // un-segmented, so completing ONE part cleared the stub and locked
3063
+ // the other out (the exact production bug).
3064
+ expect((midway!.metadata as any)?.transcribe_stub).toBe(true);
3065
+
3066
+ await worker.kick("default", att0);
3067
+ const final = await store.getNote("seg-e2e");
3068
+ expect(final!.content).toBe(
3069
+ "# 🎙️ Voice memo\n\npart text\n\npart text\n",
3070
+ );
3071
+ // Segmented notes never auto-clear the shared stub, even once every
3072
+ // part is done (pre-existing worker contract, pinned separately in
3073
+ // transcription-worker.test.ts) — out of scope here, asserted only
3074
+ // so this test doesn't silently rely on behavior this PR didn't add.
3075
+ expect((final!.metadata as any)?.transcribe_stub).toBe(true);
3076
+ } finally {
3077
+ await worker.stop();
3078
+ delete process.env.ASSETS_DIR;
3079
+ }
3080
+ });
3081
+ });
3082
+
2916
3083
  describe("DELETE /notes/:id/attachments/:attId", async () => {
2917
3084
  test("happy path: 204, DB row gone, storage file unlinked", async () => {
2918
3085
  const assetsRoot = join(tmpDir, "assets");