@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/core/src/attachment/tickets.ts +9 -0
- package/core/src/mcp-manifest.ts +5 -1
- package/core/src/mcp.ts +8 -0
- package/package.json +1 -1
- package/src/attachment-tickets.test.ts +46 -0
- package/src/attachment-tickets.ts +6 -0
- package/src/embeddings-routes.test.ts +188 -0
- package/src/embeddings-routes.ts +178 -0
- package/src/routes.ts +15 -1
- package/src/routing.test.ts +129 -0
- package/src/routing.ts +28 -0
- package/src/vault-store.ts +15 -0
- package/src/vault.test.ts +167 -0
- package/web/ui/dist/assets/index-NvwxfZcu.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-CJ6NtIwh.js +0 -61
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`
|
package/src/vault-store.ts
CHANGED
|
@@ -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");
|