@openparachute/vault 0.6.5-rc.9 → 0.6.5
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 +1 -0
- package/core/src/core.test.ts +7 -7
- package/core/src/mcp.ts +1 -1
- package/core/src/schema.ts +5 -5
- package/core/src/seed-packs.test.ts +222 -56
- package/core/src/seed-packs.ts +334 -70
- package/core/src/tag-hierarchy.ts +1 -1
- package/core/src/tag-schemas.ts +2 -2
- package/core/src/types.ts +1 -1
- package/package.json +1 -1
- package/src/admin-spa.ts +2 -1
- package/src/auth.ts +1 -1
- package/src/cli.ts +317 -53
- package/src/export-watch.ts +1 -1
- package/src/live-frame-parity.test.ts +201 -0
- package/src/module-manifest.ts +8 -0
- package/src/onboarding-seed.test.ts +82 -20
- package/src/onboarding-seed.ts +2 -2
- package/src/routes.ts +3 -3
- package/src/routing.test.ts +2 -2
- package/src/routing.ts +18 -6
- package/src/self-register.test.ts +19 -0
- package/src/self-register.ts +6 -1
- package/src/server.ts +56 -0
- package/src/services-manifest.ts +8 -0
- package/src/subscriptions.ts +100 -42
- package/src/tag-scope.ts +3 -3
- package/src/test-support/live-frame-corpus.ts +72 -0
- package/src/token-store.ts +2 -2
- package/src/transcription/build.test.ts +86 -5
- package/src/transcription/build.ts +87 -7
- package/src/transcription/capability.test.ts +25 -0
- package/src/transcription/capability.ts +27 -2
- package/src/transcription/download.test.ts +101 -0
- package/src/transcription/download.ts +71 -0
- package/src/transcription/install-python.test.ts +366 -0
- package/src/transcription/install-python.ts +471 -0
- package/src/transcription/providers/onnx-asr.test.ts +229 -0
- package/src/transcription/providers/onnx-asr.ts +239 -0
- package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
- package/src/transcription/providers/parakeet-mlx.ts +242 -0
- package/src/transcription/select.test.ts +166 -1
- package/src/transcription/select.ts +234 -1
- package/src/transcription/tiers.test.ts +197 -0
- package/src/transcription/tiers.ts +184 -0
- package/src/vault-create.test.ts +19 -14
- package/src/vault.test.ts +1 -1
- package/src/ws-server.ts +408 -0
- package/src/ws-subscribe.test.ts +474 -0
- package/src/ws-subscribe.ts +242 -0
- package/src/subscribe.test.ts +0 -609
- package/src/subscribe.ts +0 -248
package/src/services-manifest.ts
CHANGED
|
@@ -15,6 +15,14 @@ export interface ServiceEntry {
|
|
|
15
15
|
paths: string[];
|
|
16
16
|
health: string;
|
|
17
17
|
version: string;
|
|
18
|
+
/**
|
|
19
|
+
* When `true`, the hub's ws-bridge forwards `Upgrade: websocket` requests on
|
|
20
|
+
* this module's mounts to it (deny-by-default). Carried from the module's
|
|
21
|
+
* `.parachute/module.json` `websocket` field by self-registration; the hub
|
|
22
|
+
* honors either source. Optional + free-riding — extra fields survive the
|
|
23
|
+
* read/merge via the `...e` spread in `validateEntry` / `upsertService`.
|
|
24
|
+
*/
|
|
25
|
+
websocket?: boolean;
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
export interface ServicesManifest {
|
package/src/subscriptions.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Live-query subscription registry
|
|
3
|
-
*
|
|
2
|
+
* Live-query subscription registry — fans out post-commit note events to live
|
|
3
|
+
* WebSocket subscriptions (originally an SSE fan-out — design
|
|
4
|
+
* `design/2026-06-08-live-query-sse.md`; the SSE transport was removed in
|
|
5
|
+
* Phase 5 of the WS-hibernation migration).
|
|
4
6
|
*
|
|
5
7
|
* A second consumer of the post-commit hook dispatcher (`core/src/hooks.ts`),
|
|
6
8
|
* alongside the durable webhook-trigger sink. Where a trigger survives across
|
|
@@ -8,6 +10,24 @@
|
|
|
8
10
|
* connection-scoped, driving a live UI. Same event source, same predicate,
|
|
9
11
|
* different durability.
|
|
10
12
|
*
|
|
13
|
+
* ## The live transport: WebSocket (WS-hibernation migration)
|
|
14
|
+
*
|
|
15
|
+
* The manager is transport-agnostic: it emits abstract `(event, data)` tuples
|
|
16
|
+
* and a {@link SubscriptionSink} renders them for its wire.
|
|
17
|
+
* - {@link WsSink} renders a WebSocket message `{ type: event, ...data }`. The
|
|
18
|
+
* inner payload (`note` / `id` / `notes`) is pinned byte-for-byte against the
|
|
19
|
+
* shared frame-corpus fixture and is congruent with the cloud door
|
|
20
|
+
* (`parachute-cloud/workers/vault/src/live/subscriptions.ts`). See
|
|
21
|
+
* `parachute-cloud/workers/vault/docs/live-query-ws.md`.
|
|
22
|
+
*
|
|
23
|
+
* WebSocket is the SOLE live transport as of Phase 5 — the earlier SSE binding
|
|
24
|
+
* was removed once cached notes-ui bundles converged, and polling (client-side)
|
|
25
|
+
* is the floor beneath live. All subscriptions share ONE process-wide
|
|
26
|
+
* {@link SubscriptionManager}. Self-host has no hibernation (a self-run Bun box
|
|
27
|
+
* has no per-connection duration bill) — the WS binding gives bidirectionality +
|
|
28
|
+
* contract-congruence with cloud, nothing more. The Bun.serve WS integration
|
|
29
|
+
* lives in `ws-server.ts` + `ws-subscribe.ts`.
|
|
30
|
+
*
|
|
11
31
|
* ## How fan-out works
|
|
12
32
|
*
|
|
13
33
|
* All vault stores share the process-wide `defaultHookRegistry`
|
|
@@ -54,16 +74,19 @@ export const DEFAULT_MAX_SUBSCRIPTIONS_PER_VAULT = 100;
|
|
|
54
74
|
* Default bound on a single subscription's pending (unflushed) event buffer.
|
|
55
75
|
* If a slow client lets the buffer grow past this, the stream is closed — it
|
|
56
76
|
* reconnects and re-snapshots rather than the server growing memory unbounded.
|
|
77
|
+
* Only enforced for flush-tracking sinks (SSE); the WS transport delegates
|
|
78
|
+
* outgoing-buffer bounds to the Bun runtime.
|
|
57
79
|
*/
|
|
58
80
|
export const DEFAULT_MAX_BUFFERED_EVENTS = 1000;
|
|
59
81
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
82
|
+
/**
|
|
83
|
+
* The abstract sink an event is rendered to. The manager calls `send(event,
|
|
84
|
+
* data)`; each transport formats for its wire. Returns `false` when the sink is
|
|
85
|
+
* gone (the manager then drops the subscription).
|
|
86
|
+
*/
|
|
63
87
|
export interface SubscriptionSink {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
/** Close the underlying stream (teardown). Idempotent. */
|
|
88
|
+
send(event: string, data: unknown): boolean;
|
|
89
|
+
/** Close the underlying stream/socket (teardown). Idempotent. */
|
|
67
90
|
close(): void;
|
|
68
91
|
}
|
|
69
92
|
|
|
@@ -75,14 +98,56 @@ interface Subscription {
|
|
|
75
98
|
/** Raw root-tag allowlist (null = unscoped) — `noteWithinTagScope` arg. */
|
|
76
99
|
readonly tagScopeRaw: string[] | null;
|
|
77
100
|
readonly sink: SubscriptionSink;
|
|
78
|
-
/**
|
|
101
|
+
/** SSE tracks unflushed frames to bound memory; WS delegates to the runtime. */
|
|
102
|
+
readonly tracksFlush: boolean;
|
|
103
|
+
/** Whether this sub counts against the per-vault manager cap (SSE) — the WS
|
|
104
|
+
* transport enforces its own cap via the live-socket count at upgrade. */
|
|
105
|
+
readonly countsTowardCap: boolean;
|
|
106
|
+
/** Pending unflushed frame count (backpressure bound; SSE only). */
|
|
79
107
|
buffered: number;
|
|
80
108
|
readonly maxBuffered: number;
|
|
81
109
|
closed: boolean;
|
|
82
110
|
}
|
|
83
111
|
|
|
84
|
-
|
|
85
|
-
|
|
112
|
+
/** WebSocket message: `{ type: <event>, ...data }` — the event name becomes the
|
|
113
|
+
* `type` discriminator and the inner payload (`note` / `id` / `notes`)
|
|
114
|
+
* serializes as-is. The ONE place WS bytes are formatted. */
|
|
115
|
+
export function wsFrame(event: string, data: unknown): string {
|
|
116
|
+
const body = data && typeof data === "object" && !Array.isArray(data) ? (data as Record<string, unknown>) : {};
|
|
117
|
+
return JSON.stringify({ type: event, ...body });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Minimal structural shape of a Bun `ServerWebSocket` (or a test double) that
|
|
121
|
+
* {@link WsSink} needs — keeps this module free of a hard Bun-server import. */
|
|
122
|
+
export interface WsLike {
|
|
123
|
+
send(data: string): unknown;
|
|
124
|
+
close(code?: number, reason?: string): unknown;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** WebSocket sink: renders `(event, data)` to a `{ type, ...data }` message and
|
|
128
|
+
* sends it on the socket. `sendRaw` is for pre-formatted frames (the chunked
|
|
129
|
+
* snapshot the WS binding builds directly). A send on a dead socket returns
|
|
130
|
+
* false → the manager drops the sub. */
|
|
131
|
+
export class WsSink implements SubscriptionSink {
|
|
132
|
+
constructor(private readonly ws: WsLike) {}
|
|
133
|
+
send(event: string, data: unknown): boolean {
|
|
134
|
+
return this.sendRaw(wsFrame(event, data));
|
|
135
|
+
}
|
|
136
|
+
sendRaw(frame: string): boolean {
|
|
137
|
+
try {
|
|
138
|
+
this.ws.send(frame);
|
|
139
|
+
return true;
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
close(): void {
|
|
145
|
+
try {
|
|
146
|
+
this.ws.close(1011, "subscription closed");
|
|
147
|
+
} catch {
|
|
148
|
+
/* already closing/closed */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
86
151
|
}
|
|
87
152
|
|
|
88
153
|
export class SubscriptionManager {
|
|
@@ -140,9 +205,15 @@ export class SubscriptionManager {
|
|
|
140
205
|
tagScopeRaw: string[] | null;
|
|
141
206
|
sink: SubscriptionSink;
|
|
142
207
|
maxBuffered?: number;
|
|
208
|
+
/** SSE (default true) tracks unflushed frames; WS passes false. */
|
|
209
|
+
tracksFlush?: boolean;
|
|
210
|
+
/** SSE (default true) counts against the per-vault manager cap; WS passes
|
|
211
|
+
* false — the WS transport caps via the live-socket count at upgrade. */
|
|
212
|
+
countsTowardCap?: boolean;
|
|
143
213
|
}): SubscriptionHandle | null {
|
|
214
|
+
const countsTowardCap = args.countsTowardCap ?? true;
|
|
144
215
|
const current = this.countForVault(args.vaultName);
|
|
145
|
-
if (current >= this.maxPerVault) return null;
|
|
216
|
+
if (countsTowardCap && current >= this.maxPerVault) return null;
|
|
146
217
|
|
|
147
218
|
this.ensureHooks();
|
|
148
219
|
|
|
@@ -152,12 +223,14 @@ export class SubscriptionManager {
|
|
|
152
223
|
tagScopeAllowed: args.tagScopeAllowed,
|
|
153
224
|
tagScopeRaw: args.tagScopeRaw,
|
|
154
225
|
sink: args.sink,
|
|
226
|
+
tracksFlush: args.tracksFlush ?? true,
|
|
227
|
+
countsTowardCap,
|
|
155
228
|
buffered: 0,
|
|
156
229
|
maxBuffered: args.maxBuffered ?? DEFAULT_MAX_BUFFERED_EVENTS,
|
|
157
230
|
closed: false,
|
|
158
231
|
};
|
|
159
232
|
this.subs.add(sub);
|
|
160
|
-
this.perVaultCount.set(args.vaultName, current + 1);
|
|
233
|
+
if (countsTowardCap) this.perVaultCount.set(args.vaultName, current + 1);
|
|
161
234
|
|
|
162
235
|
return {
|
|
163
236
|
/** Note that a previously-buffered frame flushed to the wire. */
|
|
@@ -172,9 +245,11 @@ export class SubscriptionManager {
|
|
|
172
245
|
if (sub.closed) return;
|
|
173
246
|
sub.closed = true;
|
|
174
247
|
this.subs.delete(sub);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
248
|
+
if (sub.countsTowardCap) {
|
|
249
|
+
const n = this.perVaultCount.get(sub.vaultName) ?? 0;
|
|
250
|
+
if (n <= 1) this.perVaultCount.delete(sub.vaultName);
|
|
251
|
+
else this.perVaultCount.set(sub.vaultName, n - 1);
|
|
252
|
+
}
|
|
178
253
|
try {
|
|
179
254
|
sub.sink.close();
|
|
180
255
|
} catch {
|
|
@@ -182,33 +257,21 @@ export class SubscriptionManager {
|
|
|
182
257
|
}
|
|
183
258
|
}
|
|
184
259
|
|
|
185
|
-
/**
|
|
186
|
-
*
|
|
187
|
-
|
|
188
|
-
* so it never counts against the buffer bound.
|
|
189
|
-
*/
|
|
190
|
-
keepaliveAll(): void {
|
|
191
|
-
for (const sub of this.subs) {
|
|
192
|
-
if (sub.closed) continue;
|
|
193
|
-
const ok = sub.sink.write(":\n\n");
|
|
194
|
-
if (!ok) this.remove(sub);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** Emit a frame to one subscription, enforcing the buffer bound. */
|
|
199
|
-
private emit(sub: Subscription, frame: SseFrame): void {
|
|
260
|
+
/** Emit an `(event, data)` tuple to one subscription, enforcing the buffer
|
|
261
|
+
* bound for flush-tracking (SSE) sinks. */
|
|
262
|
+
private emit(sub: Subscription, event: string, data: unknown): void {
|
|
200
263
|
if (sub.closed) return;
|
|
201
|
-
if (sub.buffered >= sub.maxBuffered) {
|
|
264
|
+
if (sub.tracksFlush && sub.buffered >= sub.maxBuffered) {
|
|
202
265
|
// Backpressure: client can't keep up. Close → it reconnects + re-snapshots.
|
|
203
266
|
this.remove(sub);
|
|
204
267
|
return;
|
|
205
268
|
}
|
|
206
|
-
const ok = sub.sink.
|
|
269
|
+
const ok = sub.sink.send(event, data);
|
|
207
270
|
if (!ok) {
|
|
208
271
|
this.remove(sub);
|
|
209
272
|
return;
|
|
210
273
|
}
|
|
211
|
-
sub.buffered++;
|
|
274
|
+
if (sub.tracksFlush) sub.buffered++;
|
|
212
275
|
}
|
|
213
276
|
|
|
214
277
|
/** Lazily register the three broad hooks (once per manager). */
|
|
@@ -244,7 +307,7 @@ export class SubscriptionManager {
|
|
|
244
307
|
// the client ignores ids it never held. Documented low-sensitivity
|
|
245
308
|
// existence leak (see design §scope-intersection).
|
|
246
309
|
const ref = payload as DeletedNoteRef;
|
|
247
|
-
this.emit(sub,
|
|
310
|
+
this.emit(sub, "remove", { id: ref.id });
|
|
248
311
|
continue;
|
|
249
312
|
}
|
|
250
313
|
|
|
@@ -257,14 +320,14 @@ export class SubscriptionManager {
|
|
|
257
320
|
const matches = sub.matcher.match(note) && inScope;
|
|
258
321
|
|
|
259
322
|
if (matches) {
|
|
260
|
-
this.emit(sub,
|
|
323
|
+
this.emit(sub, "upsert", { note });
|
|
261
324
|
} else if (event === "updated" && inScope) {
|
|
262
325
|
// Left the set (predicate no longer true) BUT still within this
|
|
263
326
|
// token's scope, so the sub could have held it — idempotent remove
|
|
264
327
|
// (client drops the id if held, no-op otherwise). When the note is
|
|
265
328
|
// OUT of scope the sub never had it; emitting would leak its id, so
|
|
266
329
|
// we stay silent.
|
|
267
|
-
this.emit(sub,
|
|
330
|
+
this.emit(sub, "remove", { id: note.id });
|
|
268
331
|
}
|
|
269
332
|
// created that doesn't match → nothing (it was never in the set).
|
|
270
333
|
}
|
|
@@ -286,10 +349,5 @@ export interface SubscriptionHandle {
|
|
|
286
349
|
close: () => void;
|
|
287
350
|
}
|
|
288
351
|
|
|
289
|
-
/** Serialize a snapshot frame (exported for the route + tests). */
|
|
290
|
-
export function snapshotFrame(notes: Note[]): SseFrame {
|
|
291
|
-
return sseEvent("snapshot", { notes });
|
|
292
|
-
}
|
|
293
|
-
|
|
294
352
|
/** Process-wide manager — shared like `defaultHookRegistry`. */
|
|
295
353
|
export const subscriptionManager = new SubscriptionManager();
|
package/src/tag-scope.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tag-scope enforcement for tag-scoped tokens (
|
|
2
|
+
* Tag-scope enforcement for tag-scoped tokens (docs/contracts/tag-scoped-tokens.md).
|
|
3
3
|
*
|
|
4
4
|
* A token's `scoped_tags` allowlist narrows its effective access to notes
|
|
5
5
|
* carrying one of the allowlisted tags or a sub-tag thereof. The expansion
|
|
6
6
|
* to descendants happens via the per-vault `_tags/<name>` config-note
|
|
7
7
|
* hierarchy (see core/src/tag-hierarchy.ts).
|
|
8
8
|
*
|
|
9
|
-
* Auth check pseudocode (from
|
|
9
|
+
* Auth check pseudocode (from docs/contracts/tag-scoped-tokens.md):
|
|
10
10
|
*
|
|
11
11
|
* if (!hasScope(token, ...)) return forbidden();
|
|
12
12
|
* if (token.scoped_tags === null) return ok(); // unscoped
|
|
@@ -38,7 +38,7 @@ export async function expandTokenTagScope(
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Return true iff the note's tag set intersects the expanded allowlist OR
|
|
41
|
-
* — fail-open per
|
|
41
|
+
* — fail-open per docs/contracts/tag-scoped-tokens.md §Storage — any of the
|
|
42
42
|
* note's tags has a string-form root inside `rawRoots`. The string-form
|
|
43
43
|
* fallback covers the orphan-sub-tag case: a token allowlisted for
|
|
44
44
|
* `health` should still see `#health/food` even when no `_tags/health/food`
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared live-query frame corpus — MIRRORED VERBATIM from the cloud door's
|
|
3
|
+
* `parachute-cloud/workers/vault/test/fixtures/live-frame-corpus.ts` (the SINGLE
|
|
4
|
+
* source of truth). Kept byte-identical here so the self-host WS/SSE parity test
|
|
5
|
+
* asserts against the EXACT same fixture the cloud door pins — the load-bearing
|
|
6
|
+
* cross-door conformance check (WS-hibernation migration, 2026-07-04). If the
|
|
7
|
+
* canonical corpus changes upstream, re-mirror this file.
|
|
8
|
+
*
|
|
9
|
+
* Each case is a transport-neutral `(event, data)` tuple. The invariant under
|
|
10
|
+
* test: for a given case, the SSE `data:` body and the WS message carry a
|
|
11
|
+
* byte-IDENTICAL serialization of the inner payload (`notes` / `note` / `id`) —
|
|
12
|
+
* the ONLY difference is that the WS message folds the SSE `event:` NAME into a
|
|
13
|
+
* `type` discriminator (`{ type, ...data }`), because a WebSocket message has no
|
|
14
|
+
* separate event-name framing. The snapshot case additionally chunks with a
|
|
15
|
+
* `done` flag on the WS side (transport framing under the ~1 MiB message cap);
|
|
16
|
+
* its note OBJECTS still serialize identically.
|
|
17
|
+
*
|
|
18
|
+
* The notes deliberately include the drift-prone bits: unicode, nested metadata,
|
|
19
|
+
* a null value, an empty array, quotes/newlines in content, and stable key
|
|
20
|
+
* ordering — anything a careless re-serialization would reorder or mangle.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** A note-shaped payload (loosely typed — the parity test cares about bytes, not
|
|
24
|
+
* the full Note interface; the wire carries whatever the store returns). */
|
|
25
|
+
export interface CorpusNote {
|
|
26
|
+
id: string;
|
|
27
|
+
path: string;
|
|
28
|
+
content: string;
|
|
29
|
+
tags: string[];
|
|
30
|
+
metadata: Record<string, unknown>;
|
|
31
|
+
createdAt: string;
|
|
32
|
+
updatedAt: string;
|
|
33
|
+
extension: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const NOTE_A: CorpusNote = {
|
|
37
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
38
|
+
path: "greetings/héllo",
|
|
39
|
+
content: 'line one\nline "two" with quotes\n#greeting [[world]]',
|
|
40
|
+
tags: ["greeting", "watch"],
|
|
41
|
+
metadata: { mood: "warm", priority: 5, pinned: true, note: null, aliases: [] },
|
|
42
|
+
createdAt: "2026-07-04T00:00:00.000Z",
|
|
43
|
+
updatedAt: "2026-07-04T00:00:01.000Z",
|
|
44
|
+
extension: "md",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const NOTE_B: CorpusNote = {
|
|
48
|
+
id: "22222222-2222-4222-8222-222222222222",
|
|
49
|
+
path: "meetings/2026-07-04",
|
|
50
|
+
content: "standup — ☂️ parachute cloud",
|
|
51
|
+
tags: ["meeting"],
|
|
52
|
+
metadata: { attendees: ["aaron", "uni"], count: 2 },
|
|
53
|
+
createdAt: "2026-07-04T09:00:00.000Z",
|
|
54
|
+
updatedAt: "2026-07-04T09:30:00.000Z",
|
|
55
|
+
extension: "md",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export interface FrameCase {
|
|
59
|
+
name: string;
|
|
60
|
+
event: "snapshot" | "upsert" | "remove";
|
|
61
|
+
/** The inner payload — the SSE `data:` body is exactly `JSON.stringify(data)`;
|
|
62
|
+
* the WS message is `JSON.stringify({ type: event, ...data })`. */
|
|
63
|
+
data: Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const FRAME_CORPUS: FrameCase[] = [
|
|
67
|
+
{ name: "snapshot (two notes)", event: "snapshot", data: { notes: [NOTE_A, NOTE_B] } },
|
|
68
|
+
{ name: "snapshot (empty set)", event: "snapshot", data: { notes: [] } },
|
|
69
|
+
{ name: "upsert (rich note)", event: "upsert", data: { note: NOTE_A } },
|
|
70
|
+
{ name: "upsert (unicode note)", event: "upsert", data: { note: NOTE_B } },
|
|
71
|
+
{ name: "remove (id)", event: "remove", data: { id: NOTE_A.id } },
|
|
72
|
+
];
|
package/src/token-store.ts
CHANGED
|
@@ -54,7 +54,7 @@ export interface Token {
|
|
|
54
54
|
* access is the intersection of `scopes` and notes carrying one of these
|
|
55
55
|
* tags or a sub-tag thereof (hierarchy expansion via getTagDescendants).
|
|
56
56
|
* NULL = unscoped, full vault access per `scopes`. See
|
|
57
|
-
*
|
|
57
|
+
* docs/contracts/tag-scoped-tokens.md.
|
|
58
58
|
*/
|
|
59
59
|
scoped_tags: string[] | null;
|
|
60
60
|
/**
|
|
@@ -301,7 +301,7 @@ export function markMcpMintLedgerRevoked(
|
|
|
301
301
|
* Returns display ID + label pairs (no token-hash exposure) so error
|
|
302
302
|
* envelopes can name the offending tokens for the operator. The match is
|
|
303
303
|
* exact on the root name — `scoped_tags` only ever stores roots per
|
|
304
|
-
*
|
|
304
|
+
* docs/contracts/tag-scoped-tokens.md.
|
|
305
305
|
*/
|
|
306
306
|
export function findTokensReferencingTag(
|
|
307
307
|
db: Database,
|
|
@@ -6,6 +6,9 @@ import {
|
|
|
6
6
|
detectCompiler,
|
|
7
7
|
compilerInstallHint,
|
|
8
8
|
cliSourceUrl,
|
|
9
|
+
patchMainCppBackendInit,
|
|
10
|
+
BACKEND_INIT_PATCH_ANCHOR,
|
|
11
|
+
BACKEND_INIT_PATCH_MARKER,
|
|
9
12
|
CLI_SOURCE_FILES,
|
|
10
13
|
CXX_CANDIDATES,
|
|
11
14
|
TRANSCRIBE_CPP_SOURCE_REF,
|
|
@@ -17,10 +20,26 @@ import {
|
|
|
17
20
|
* transcribe-cli build tests (scribe-fold Phase 2b). Pure recipe shape +
|
|
18
21
|
* fully-mocked orchestration — NO network, NO real compiler (the real compile
|
|
19
22
|
* is exercised only by the install-time live verify). Covers the toolchain-
|
|
20
|
-
* absent, fetch-failure, compile-failure, and success branches,
|
|
21
|
-
* "never leave a half-built binary" invariant.
|
|
23
|
+
* absent, fetch-failure, patch-failure, compile-failure, and success branches,
|
|
24
|
+
* and the "never leave a half-built binary" invariant.
|
|
22
25
|
*/
|
|
23
26
|
|
|
27
|
+
/** A minimal stand-in for the pinned main.cpp: log-sink install + the batch-
|
|
28
|
+
* mode comment the vault#534 patch anchors on. */
|
|
29
|
+
const MAIN_CPP_FIXTURE = [
|
|
30
|
+
"int main(int argc, char ** argv) {",
|
|
31
|
+
" if (!args.quiet) {",
|
|
32
|
+
" transcribe_log_set(log_cb, nullptr);",
|
|
33
|
+
" }",
|
|
34
|
+
"",
|
|
35
|
+
BACKEND_INIT_PATCH_ANCHOR,
|
|
36
|
+
" // the model ONCE and reuses the context across all files.",
|
|
37
|
+
" if (!args.batch_file.empty()) {",
|
|
38
|
+
" }",
|
|
39
|
+
"}",
|
|
40
|
+
"",
|
|
41
|
+
].join("\n");
|
|
42
|
+
|
|
24
43
|
describe("detectCompiler", () => {
|
|
25
44
|
test("returns the first candidate found (c++ preferred)", () => {
|
|
26
45
|
const which = (c: string) => (c === "c++" ? "/usr/bin/c++" : null);
|
|
@@ -106,15 +125,31 @@ describe("buildCompileCommand — the proven recipe shape", () => {
|
|
|
106
125
|
|
|
107
126
|
/** Build a deps object with call-recording mocks; overrides win. */
|
|
108
127
|
function mkDeps(over: Partial<BuildCliDeps> = {}): BuildCliDeps & {
|
|
109
|
-
calls: {
|
|
128
|
+
calls: {
|
|
129
|
+
fetch: number;
|
|
130
|
+
compile: number;
|
|
131
|
+
removed: string[];
|
|
132
|
+
renamed: Array<[string, string]>;
|
|
133
|
+
written: Array<[string, string]>;
|
|
134
|
+
};
|
|
110
135
|
} {
|
|
111
|
-
const calls = {
|
|
136
|
+
const calls = {
|
|
137
|
+
fetch: 0,
|
|
138
|
+
compile: 0,
|
|
139
|
+
removed: [] as string[],
|
|
140
|
+
renamed: [] as Array<[string, string]>,
|
|
141
|
+
written: [] as Array<[string, string]>,
|
|
142
|
+
};
|
|
112
143
|
const deps: BuildCliDeps = {
|
|
113
144
|
platform: "darwin",
|
|
114
145
|
which: (c) => (c === "c++" ? "/usr/bin/c++" : null),
|
|
115
146
|
fetchSource: async () => {
|
|
116
147
|
calls.fetch++;
|
|
117
148
|
},
|
|
149
|
+
readFile: () => MAIN_CPP_FIXTURE,
|
|
150
|
+
writeFile: (p, content) => {
|
|
151
|
+
calls.written.push([p, content]);
|
|
152
|
+
},
|
|
118
153
|
compile: async (): Promise<CompileResult> => {
|
|
119
154
|
calls.compile++;
|
|
120
155
|
return { exitCode: 0, stderr: "" };
|
|
@@ -134,8 +169,36 @@ function mkDeps(over: Partial<BuildCliDeps> = {}): BuildCliDeps & {
|
|
|
134
169
|
const INPUT = { srcDir: "/t/.src", libsDir: "/t/libs", binPath: "/t/bin/transcribe-cli" };
|
|
135
170
|
const TMP = `${INPUT.binPath}.building`; // the temp output the build compiles to
|
|
136
171
|
|
|
172
|
+
describe("patchMainCppBackendInit — the vault#534 backend-init source patch", () => {
|
|
173
|
+
test("injects transcribe_init_backends_default() before the batch-mode anchor", () => {
|
|
174
|
+
const patched = patchMainCppBackendInit(MAIN_CPP_FIXTURE);
|
|
175
|
+
expect(patched).toContain("transcribe_init_backends_default();");
|
|
176
|
+
expect(patched).toContain(BACKEND_INIT_PATCH_MARKER);
|
|
177
|
+
// The init call lands BEFORE the inference paths (the anchor) and AFTER
|
|
178
|
+
// the log-sink install, so backend plugin-load logs route through the sink.
|
|
179
|
+
const initAt = patched.indexOf("transcribe_init_backends_default();");
|
|
180
|
+
expect(initAt).toBeGreaterThan(patched.indexOf("transcribe_log_set(log_cb, nullptr);"));
|
|
181
|
+
expect(initAt).toBeLessThan(patched.indexOf(BACKEND_INIT_PATCH_ANCHOR));
|
|
182
|
+
// Everything else is preserved verbatim: removing the injected block
|
|
183
|
+
// restores the original source exactly.
|
|
184
|
+
const [before, after] = patched.split(/ \/\/ \[parachute-vault[\s\S]*?transcribe_init_backends_default\(\);\n\n/);
|
|
185
|
+
expect(`${before}${after}`).toBe(MAIN_CPP_FIXTURE);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("idempotent: an already-patched source is returned unchanged", () => {
|
|
189
|
+
const once = patchMainCppBackendInit(MAIN_CPP_FIXTURE);
|
|
190
|
+
expect(patchMainCppBackendInit(once)).toBe(once);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("anchor missing ⇒ throws a loud, actionable error naming the pin + vault#534", () => {
|
|
194
|
+
expect(() => patchMainCppBackendInit("int main() { return 0; }\n")).toThrow(
|
|
195
|
+
/anchor not found[\s\S]*vault#534/,
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
137
200
|
describe("buildTranscribeCli — orchestration branches", () => {
|
|
138
|
-
test("SUCCESS: fetch → compile ok → temp exists ⇒ promote (rename) + { ok:true }", async () => {
|
|
201
|
+
test("SUCCESS: fetch → patch → compile ok → temp exists ⇒ promote (rename) + { ok:true }", async () => {
|
|
139
202
|
const deps = mkDeps();
|
|
140
203
|
const r = await buildTranscribeCli(INPUT, deps);
|
|
141
204
|
expect(r.ok).toBe(true);
|
|
@@ -149,11 +212,29 @@ describe("buildTranscribeCli — orchestration branches", () => {
|
|
|
149
212
|
}
|
|
150
213
|
expect(deps.calls.fetch).toBe(1);
|
|
151
214
|
expect(deps.calls.compile).toBe(1);
|
|
215
|
+
// the vault#534 backend-init patch was written into the fetched main.cpp
|
|
216
|
+
expect(deps.calls.written).toHaveLength(1);
|
|
217
|
+
const [patchedPath, patchedContent] = deps.calls.written[0]!;
|
|
218
|
+
expect(patchedPath).toBe(join(INPUT.srcDir, "examples", "cli", "main.cpp"));
|
|
219
|
+
expect(patchedContent).toContain("transcribe_init_backends_default();");
|
|
152
220
|
// only the TEMP is cleared pre-compile; the fresh temp is renamed into place
|
|
153
221
|
expect(deps.calls.removed).toEqual([TMP]);
|
|
154
222
|
expect(deps.calls.renamed).toEqual([[TMP, INPUT.binPath]]);
|
|
155
223
|
});
|
|
156
224
|
|
|
225
|
+
test("PATCH FAILURE: anchor missing in fetched source ⇒ { ok:false, stage:'patch' }, no write, no compile", async () => {
|
|
226
|
+
const deps = mkDeps({ readFile: () => "int main() { return 0; }\n" });
|
|
227
|
+
const r = await buildTranscribeCli(INPUT, deps);
|
|
228
|
+
expect(r.ok).toBe(false);
|
|
229
|
+
if (!r.ok) {
|
|
230
|
+
expect(r.stage).toBe("patch");
|
|
231
|
+
expect(r.message).toContain("anchor not found");
|
|
232
|
+
expect(r.message).toContain("vault#534");
|
|
233
|
+
}
|
|
234
|
+
expect(deps.calls.written).toEqual([]);
|
|
235
|
+
expect(deps.calls.compile).toBe(0);
|
|
236
|
+
});
|
|
237
|
+
|
|
157
238
|
test("TOOLCHAIN ABSENT: no compiler ⇒ { ok:false, stage:'toolchain' }, no fetch/compile", async () => {
|
|
158
239
|
const deps = mkDeps({ which: () => null });
|
|
159
240
|
const r = await buildTranscribeCli(INPUT, deps);
|
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
* `install.ts` + the provider-seam design doc. This module closes that gap: it
|
|
7
7
|
* fetches the CLI's source (`examples/cli/main.cpp` + `examples/common/wav.cpp`
|
|
8
8
|
* + the public headers) from the transcribe.cpp repo pinned at the v0.1.1 tag
|
|
9
|
-
* commit,
|
|
10
|
-
*
|
|
9
|
+
* commit, applies the **vault#534 backend-init patch** (see
|
|
10
|
+
* `patchMainCppBackendInit` below — temporary until upstream ships the fix),
|
|
11
|
+
* then compiles it with the host C++ compiler against the extracted prebuilt
|
|
12
|
+
* dylibs. The result is a ~127KB driver that links the prebuilt
|
|
11
13
|
* `libtranscribe` at runtime — **no ggml / Metal rebuild**.
|
|
12
14
|
*
|
|
13
15
|
* ## The proven recipe (live-verified 2026-07-03, macOS arm64 M4)
|
|
@@ -34,10 +36,11 @@
|
|
|
34
36
|
* ## Testability
|
|
35
37
|
*
|
|
36
38
|
* `buildTranscribeCli` is a pure orchestrator over injected side effects
|
|
37
|
-
* (`which` / `fetchSource` / `
|
|
38
|
-
* provider's `SpawnRunner` seam. Tests exercise
|
|
39
|
-
* absent, fetch failure,
|
|
40
|
-
*
|
|
39
|
+
* (`which` / `fetchSource` / `readFile` / `writeFile` / `compile` / `exists` /
|
|
40
|
+
* `removeBin`), mirroring the provider's `SpawnRunner` seam. Tests exercise
|
|
41
|
+
* every branch — toolchain absent, fetch failure, patch failure, compile
|
|
42
|
+
* failure, success — with no network and no real compiler. The real compile is
|
|
43
|
+
* exercised only by the install-time live verify.
|
|
41
44
|
*/
|
|
42
45
|
|
|
43
46
|
import { join } from "path";
|
|
@@ -78,6 +81,63 @@ export function cliSourceUrl(repoPath: string): string {
|
|
|
78
81
|
return `${RAW_BASE}/${repoPath}`;
|
|
79
82
|
}
|
|
80
83
|
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Build-time source patch: unconditional backend init (vault#534 blocker 2)
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/** Marker comment baked into the injected code — makes the patch idempotent
|
|
89
|
+
* and greppable in a fetched source tree. */
|
|
90
|
+
export const BACKEND_INIT_PATCH_MARKER = "[parachute-vault vault#534 backend-init patch]";
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The anchor line in the pinned `examples/cli/main.cpp` the patch injects
|
|
94
|
+
* before: the batch-mode comment that opens the inference half of `main()`,
|
|
95
|
+
* directly AFTER the log-sink install (so backend plugin-load logs route
|
|
96
|
+
* through the CLI's log callback) and after the `--list-devices` early return.
|
|
97
|
+
* Unique at the pinned ref (verified); if the pin ever advances and this
|
|
98
|
+
* anchor drifts, the build fails LOUDLY (stage "patch") so the patch gets
|
|
99
|
+
* re-evaluated against the new source instead of silently shipping a CLI that
|
|
100
|
+
* can't transcribe on Linux.
|
|
101
|
+
*/
|
|
102
|
+
export const BACKEND_INIT_PATCH_ANCHOR =
|
|
103
|
+
" // Batch mode: --batch reads a file list, one wav path per line. Loads";
|
|
104
|
+
|
|
105
|
+
const BACKEND_INIT_PATCH_BLOCK = ` // ${BACKEND_INIT_PATCH_MARKER}
|
|
106
|
+
// Upstream (at the pinned ${TRANSCRIBE_CPP_SOURCE_REF.slice(0, 12)}) only calls
|
|
107
|
+
// transcribe_init_backends_default() in the --list-devices path. On Linux
|
|
108
|
+
// the CPU backends are dlopen plugins (libggml-cpu-*.so), so with no init
|
|
109
|
+
// the ggml device registry is empty and EVERY transcription fails
|
|
110
|
+
// ("whisper: failed to initialize CPU backend", exit 1). macOS is masked:
|
|
111
|
+
// its libtranscribe.dylib direct-links libggml-cpu, which self-registers
|
|
112
|
+
// at library load. Init the default backends unconditionally before any
|
|
113
|
+
// inference (batch or single-file). Verified fix on real Linux containers,
|
|
114
|
+
// both arches — see vault#534. DROP THIS PATCH when upstream ships the fix
|
|
115
|
+
// and the source pin advances past it.
|
|
116
|
+
transcribe_init_backends_default();
|
|
117
|
+
|
|
118
|
+
`;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Apply the vault#534 backend-init patch to the fetched `main.cpp` source.
|
|
122
|
+
* Pure (string → string) so tests pin the behavior without a filesystem.
|
|
123
|
+
* Idempotent: an already-patched source is returned unchanged. Throws with a
|
|
124
|
+
* clear operator-facing message when the anchor is missing — the caller
|
|
125
|
+
* surfaces it as a `stage: "patch"` build failure (fail loudly; never compile
|
|
126
|
+
* an unpatched CLI on the assumption it'll work).
|
|
127
|
+
*/
|
|
128
|
+
export function patchMainCppBackendInit(source: string): string {
|
|
129
|
+
if (source.includes(BACKEND_INIT_PATCH_MARKER)) return source;
|
|
130
|
+
const idx = source.indexOf(BACKEND_INIT_PATCH_ANCHOR);
|
|
131
|
+
if (idx === -1) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`backend-init patch anchor not found in examples/cli/main.cpp at ${TRANSCRIBE_CPP_SOURCE_REF.slice(0, 12)} — ` +
|
|
134
|
+
`the pinned source no longer matches the vault#534 patch. If the pin advanced, check whether upstream ` +
|
|
135
|
+
`now calls transcribe_init_backends_default() unconditionally (then drop the patch); otherwise re-anchor it.`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return source.slice(0, idx) + BACKEND_INIT_PATCH_BLOCK + source.slice(idx);
|
|
139
|
+
}
|
|
140
|
+
|
|
81
141
|
/** C++ compiler binaries we probe for, in preference order. */
|
|
82
142
|
export const CXX_CANDIDATES = ["c++", "clang++", "g++"] as const;
|
|
83
143
|
|
|
@@ -158,6 +218,10 @@ export interface BuildCliDeps {
|
|
|
158
218
|
which: (cmd: string) => string | null | undefined;
|
|
159
219
|
/** Fetch `files` (repo-relative) into `srcDir`. Throws on network failure. */
|
|
160
220
|
fetchSource: (files: readonly string[], srcDir: string) => Promise<void>;
|
|
221
|
+
/** Read a fetched source file as UTF-8 (production: `fs.readFileSync`). */
|
|
222
|
+
readFile: (p: string) => string;
|
|
223
|
+
/** Write a patched source file (production: `fs.writeFileSync`). */
|
|
224
|
+
writeFile: (p: string, content: string) => void;
|
|
161
225
|
/** Run the compiler argv. */
|
|
162
226
|
compile: (cmd: string[]) => CompileResult | Promise<CompileResult>;
|
|
163
227
|
/** Existence probe (production: `fs.existsSync`). */
|
|
@@ -185,7 +249,7 @@ export type CliBuildResult =
|
|
|
185
249
|
| {
|
|
186
250
|
ok: false;
|
|
187
251
|
/** Which stage failed — drives the operator guidance. */
|
|
188
|
-
stage: "toolchain" | "fetch" | "compile";
|
|
252
|
+
stage: "toolchain" | "fetch" | "patch" | "compile";
|
|
189
253
|
message: string;
|
|
190
254
|
compiler?: string;
|
|
191
255
|
/** The compile argv (as a string) when we got far enough to build one. */
|
|
@@ -223,6 +287,22 @@ export async function buildTranscribeCli(
|
|
|
223
287
|
};
|
|
224
288
|
}
|
|
225
289
|
|
|
290
|
+
// Apply the vault#534 backend-init source patch BEFORE compiling — without
|
|
291
|
+
// it the built CLI exits 1 on every transcription on Linux (dlopen'd CPU
|
|
292
|
+
// backends are never registered). A missing anchor is a loud, typed failure,
|
|
293
|
+
// never a silent skip.
|
|
294
|
+
const mainCppPath = join(input.srcDir, "examples", "cli", "main.cpp");
|
|
295
|
+
try {
|
|
296
|
+
deps.writeFile(mainCppPath, patchMainCppBackendInit(deps.readFile(mainCppPath)));
|
|
297
|
+
} catch (err) {
|
|
298
|
+
return {
|
|
299
|
+
ok: false,
|
|
300
|
+
stage: "patch",
|
|
301
|
+
compiler,
|
|
302
|
+
message: err instanceof Error ? err.message : String(err),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
226
306
|
const common = { platform: deps.platform, compiler, srcDir: input.srcDir, libsDir: input.libsDir };
|
|
227
307
|
// The reported command targets the FINAL binPath (what an operator would run
|
|
228
308
|
// by hand to reproduce). The actual compile writes to a temp sibling and is
|