@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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-query frame PARITY + the pure WS helpers (WS-hibernation migration,
|
|
3
|
+
* self-host door).
|
|
4
|
+
*
|
|
5
|
+
* The load-bearing CROSS-DOOR conformance check: for the shared frame corpus
|
|
6
|
+
* (mirrored byte-for-byte from the cloud door), the self-host WS message folds
|
|
7
|
+
* the event name into a `type` discriminator (plus the snapshot `done` chunk
|
|
8
|
+
* flag) over an inner payload (`notes` / `note` / `id`) that serializes
|
|
9
|
+
* byte-IDENTICALLY to the corpus. Because the corpus AND the `wsFrame` formatter
|
|
10
|
+
* are byte-shaped identical to cloud's, this transitively proves: self-host WS
|
|
11
|
+
* bytes === corpus === cloud WS bytes. (SSE was the original transport; it was
|
|
12
|
+
* removed in Phase 5 — WebSocket is now the sole live transport, polling the
|
|
13
|
+
* client floor.)
|
|
14
|
+
*
|
|
15
|
+
* Plus unit coverage of the pure helpers that back the Bun.serve integration:
|
|
16
|
+
* snapshot chunking, first-message parsing, verb-rank, and the query rejects.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect } from "bun:test";
|
|
19
|
+
import { wsFrame, WsSink } from "./subscriptions.ts";
|
|
20
|
+
import {
|
|
21
|
+
buildSnapshotFrames,
|
|
22
|
+
parseClientMessage,
|
|
23
|
+
vaultVerbRank,
|
|
24
|
+
sameTagScope,
|
|
25
|
+
validateWsSubscribeQuery,
|
|
26
|
+
subscriptionCapResponse,
|
|
27
|
+
urlFromQuery,
|
|
28
|
+
SNAPSHOT_CHUNK_MAX_NOTES,
|
|
29
|
+
WS_CLOSE,
|
|
30
|
+
} from "./ws-subscribe.ts";
|
|
31
|
+
import { unsupportedSubscriptionReason } from "./live-match.ts";
|
|
32
|
+
import { FRAME_CORPUS, NOTE_A } from "./test-support/live-frame-corpus.ts";
|
|
33
|
+
|
|
34
|
+
describe("live-frame parity — self-host WS bytes === corpus === cloud WS bytes", () => {
|
|
35
|
+
for (const c of FRAME_CORPUS) {
|
|
36
|
+
it(`${c.name}: WS message folds the event into \`type\` over the corpus payload`, () => {
|
|
37
|
+
const ws = wsFrame(c.event, c.data);
|
|
38
|
+
|
|
39
|
+
// WS message is `{ type, ...data }` — the event name folds into `type`.
|
|
40
|
+
const parsedWs = JSON.parse(ws) as Record<string, unknown>;
|
|
41
|
+
expect(parsedWs.type).toBe(c.event);
|
|
42
|
+
const { type, ...rest } = parsedWs;
|
|
43
|
+
void type;
|
|
44
|
+
expect(rest).toEqual(c.data);
|
|
45
|
+
|
|
46
|
+
// BYTE parity of the inner payload: whatever value the event carries
|
|
47
|
+
// (`notes` / `note` / `id`) serializes IDENTICALLY to the corpus
|
|
48
|
+
// serialization (the cross-door invariant).
|
|
49
|
+
const innerKey = c.event === "snapshot" ? "notes" : c.event === "upsert" ? "note" : "id";
|
|
50
|
+
const innerBytes = JSON.stringify((c.data as Record<string, unknown>)[innerKey]);
|
|
51
|
+
expect(ws.includes(innerBytes)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("sink classes render through the ONE formatter", () => {
|
|
57
|
+
it("WsSink.send emits exactly wsFrame(event, data)", () => {
|
|
58
|
+
const sent: string[] = [];
|
|
59
|
+
const sink = new WsSink({ send: (d: string) => sent.push(d), close: () => {} });
|
|
60
|
+
for (const c of FRAME_CORPUS) {
|
|
61
|
+
sink.send(c.event, c.data);
|
|
62
|
+
expect(sent.at(-1)).toBe(wsFrame(c.event, c.data));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("buildSnapshotFrames — chunking + done flag", () => {
|
|
68
|
+
it("empty set → exactly one done:true frame", () => {
|
|
69
|
+
const frames = buildSnapshotFrames([]);
|
|
70
|
+
expect(frames.length).toBe(1);
|
|
71
|
+
expect(JSON.parse(frames[0]!)).toEqual({ type: "snapshot", notes: [], done: true });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("small set → one done:true frame carrying every note", () => {
|
|
75
|
+
const frames = buildSnapshotFrames([NOTE_A as any, NOTE_A as any]);
|
|
76
|
+
expect(frames.length).toBe(1);
|
|
77
|
+
const f = JSON.parse(frames[0]!);
|
|
78
|
+
expect(f.done).toBe(true);
|
|
79
|
+
expect(f.notes.length).toBe(2);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("> SNAPSHOT_CHUNK_MAX_NOTES → multiple frames, only the last done:true, concat = full set", () => {
|
|
83
|
+
const many = Array.from({ length: SNAPSHOT_CHUNK_MAX_NOTES + 5 }, (_, i) => ({ ...NOTE_A, id: `n-${i}` }));
|
|
84
|
+
const frames = buildSnapshotFrames(many as any);
|
|
85
|
+
expect(frames.length).toBeGreaterThan(1);
|
|
86
|
+
const parsed = frames.map((f) => JSON.parse(f));
|
|
87
|
+
expect(parsed.filter((p) => p.done).length).toBe(1);
|
|
88
|
+
expect(parsed[parsed.length - 1]!.done).toBe(true);
|
|
89
|
+
parsed.slice(0, -1).forEach((p) => expect(p.done).toBe(false));
|
|
90
|
+
const concat = parsed.flatMap((p) => p.notes);
|
|
91
|
+
expect(concat.map((n: any) => n.id)).toEqual(many.map((n) => n.id));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("each frame stays under the WS message ceiling with large notes", () => {
|
|
95
|
+
const big = Array.from({ length: 8 }, (_, i) => ({ ...NOTE_A, id: `big-${i}`, content: "x".repeat(200_000) }));
|
|
96
|
+
const frames = buildSnapshotFrames(big as any);
|
|
97
|
+
expect(frames.length).toBeGreaterThan(1);
|
|
98
|
+
for (const f of frames) expect(new TextEncoder().encode(f).byteLength).toBeLessThan(1_000_000);
|
|
99
|
+
expect(frames.flatMap((f) => JSON.parse(f).notes).length).toBe(8);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("parseClientMessage", () => {
|
|
104
|
+
it("valid auth", () => {
|
|
105
|
+
expect(parseClientMessage(JSON.stringify({ type: "auth", token: "t" }))).toEqual({ kind: "auth", token: "t" });
|
|
106
|
+
});
|
|
107
|
+
it("auth without a token → malformed", () => {
|
|
108
|
+
expect(parseClientMessage(JSON.stringify({ type: "auth" })).kind).toBe("malformed");
|
|
109
|
+
expect(parseClientMessage(JSON.stringify({ type: "auth", token: "" })).kind).toBe("malformed");
|
|
110
|
+
});
|
|
111
|
+
it("non-json / non-object → malformed", () => {
|
|
112
|
+
expect(parseClientMessage("not json").kind).toBe("malformed");
|
|
113
|
+
expect(parseClientMessage(JSON.stringify([1, 2])).kind).toBe("malformed");
|
|
114
|
+
});
|
|
115
|
+
it("other typed message → other", () => {
|
|
116
|
+
expect(parseClientMessage(JSON.stringify({ type: "hello" }))).toEqual({ kind: "other", type: "hello" });
|
|
117
|
+
});
|
|
118
|
+
it("decodes a Uint8Array frame", () => {
|
|
119
|
+
const bytes = new TextEncoder().encode(JSON.stringify({ type: "auth", token: "b" }));
|
|
120
|
+
expect(parseClientMessage(bytes)).toEqual({ kind: "auth", token: "b" });
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("vaultVerbRank — narrow-or-equal ordering", () => {
|
|
125
|
+
it("ranks read < write < admin, -1 for none/other-vault", () => {
|
|
126
|
+
expect(vaultVerbRank(["vault:v:read"], "v")).toBe(0);
|
|
127
|
+
expect(vaultVerbRank(["vault:v:write"], "v")).toBe(1);
|
|
128
|
+
expect(vaultVerbRank(["vault:v:admin"], "v")).toBe(2);
|
|
129
|
+
expect(vaultVerbRank(["vault:other:read"], "v")).toBe(-1);
|
|
130
|
+
expect(vaultVerbRank([], "v")).toBe(-1);
|
|
131
|
+
});
|
|
132
|
+
it("a widen is strictly greater (the 4403 trigger)", () => {
|
|
133
|
+
expect(vaultVerbRank(["vault:v:admin"], "v") > vaultVerbRank(["vault:v:read"], "v")).toBe(true);
|
|
134
|
+
expect(vaultVerbRank(["vault:v:read"], "v") > vaultVerbRank(["vault:v:admin"], "v")).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("sameTagScope — the WS re-auth tag-scope-delta gate", () => {
|
|
139
|
+
it("both unscoped (null) → same", () => {
|
|
140
|
+
expect(sameTagScope(null, null)).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
it("null vs scoped → differ (a drop/gain of scoping)", () => {
|
|
143
|
+
expect(sameTagScope(null, ["work/eng"])).toBe(false);
|
|
144
|
+
expect(sameTagScope(["work/eng"], null)).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
it("same set (order/dupe-insensitive) → same", () => {
|
|
147
|
+
expect(sameTagScope(["a", "b"], ["b", "a"])).toBe(true);
|
|
148
|
+
expect(sameTagScope(["a", "a", "b"], ["b", "a"])).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
it("different membership → differ", () => {
|
|
151
|
+
expect(sameTagScope(["work/eng"], ["work/sales"])).toBe(false);
|
|
152
|
+
expect(sameTagScope(["a"], ["a", "b"])).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("validateWsSubscribeQuery — same rejects as the SSE route (byte-identical bodies)", () => {
|
|
157
|
+
const reject = async (q: string) => {
|
|
158
|
+
const v = validateWsSubscribeQuery(new URL(`http://x/vault/v/api/subscribe?${q}`));
|
|
159
|
+
expect("error" in v).toBe(true);
|
|
160
|
+
const body = await (v as { error: Response }).error.json();
|
|
161
|
+
expect((v as { error: Response }).error.status).toBe(400);
|
|
162
|
+
expect(body.code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
|
|
163
|
+
};
|
|
164
|
+
it("rejects the URL-reachable unsupported shapes: search / near / cursor / has_links", async () => {
|
|
165
|
+
await reject("search=hi");
|
|
166
|
+
await reject("near[note_id]=abc");
|
|
167
|
+
await reject("cursor=xyz");
|
|
168
|
+
await reject("has_links=true");
|
|
169
|
+
});
|
|
170
|
+
it("accepts a supported query", () => {
|
|
171
|
+
const v = validateWsSubscribeQuery(new URL("http://x/vault/v/api/subscribe?tag=chat&path_prefix=meetings/"));
|
|
172
|
+
expect("queryOpts" in v).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
it("shares the SSE route's queryOpts-level guard (cursor/has_links/date filters)", () => {
|
|
175
|
+
// The belt-and-suspenders layer both doors + the SSE route route through:
|
|
176
|
+
// a date filter isn't expressible as a flat URL param (removed 0.6.4), but
|
|
177
|
+
// if one ever reaches queryOpts it is rejected identically.
|
|
178
|
+
expect(unsupportedSubscriptionReason({ cursor: "x" } as any)).toBeTruthy();
|
|
179
|
+
expect(unsupportedSubscriptionReason({ hasLinks: true } as any)).toBeTruthy();
|
|
180
|
+
expect(unsupportedSubscriptionReason({ dateFrom: "2026-01-01" } as any)).toBeTruthy();
|
|
181
|
+
expect(unsupportedSubscriptionReason({ dateFilter: { field: "created_at" } } as any)).toBeTruthy();
|
|
182
|
+
expect(unsupportedSubscriptionReason({ tags: ["chat"] } as any)).toBeNull();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("misc pure helpers", () => {
|
|
187
|
+
it("subscriptionCapResponse → 503 SUBSCRIPTION_CAP_REACHED", async () => {
|
|
188
|
+
const res = subscriptionCapResponse();
|
|
189
|
+
expect(res.status).toBe(503);
|
|
190
|
+
expect((await res.json()).code).toBe("SUBSCRIPTION_CAP_REACHED");
|
|
191
|
+
});
|
|
192
|
+
it("urlFromQuery reconstructs searchParams from a raw query string", () => {
|
|
193
|
+
expect(urlFromQuery("?tag=chat&a=b").searchParams.get("tag")).toBe("chat");
|
|
194
|
+
expect(urlFromQuery("").search).toBe("");
|
|
195
|
+
});
|
|
196
|
+
it("WS_CLOSE carries the four application close codes", () => {
|
|
197
|
+
expect([WS_CLOSE.PROTOCOL, WS_CLOSE.UNAUTHORIZED, WS_CLOSE.FORBIDDEN, WS_CLOSE.AUTH_TIMEOUT]).toEqual([
|
|
198
|
+
4400, 4401, 4403, 4408,
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
});
|
package/src/module-manifest.ts
CHANGED
|
@@ -48,6 +48,14 @@ export interface VaultModuleManifest {
|
|
|
48
48
|
readonly paths: readonly string[];
|
|
49
49
|
readonly health: string;
|
|
50
50
|
readonly stripPrefix?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* When `true`, vault's daemon accepts WebSocket upgrades (the live-query WS
|
|
53
|
+
* binding) and the hub's ws-bridge forwards `Upgrade: websocket` requests on
|
|
54
|
+
* vault's mounts. DENY BY DEFAULT on the hub side. Carried onto the
|
|
55
|
+
* self-registered services.json row too (`ServiceEntry.websocket`); the hub
|
|
56
|
+
* honors either source. See `parachute-hub/src/ws-bridge.ts`.
|
|
57
|
+
*/
|
|
58
|
+
readonly websocket?: boolean;
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
/**
|
|
@@ -23,24 +23,29 @@ import { BunStore } from "./vault-store.ts";
|
|
|
23
23
|
import { seedOnboardingNotes } from "./onboarding-seed.ts";
|
|
24
24
|
import {
|
|
25
25
|
applySeedPack,
|
|
26
|
+
CAPTURE_ANYTHING_PATH,
|
|
26
27
|
CONNECT_AI_PATH,
|
|
27
28
|
GETTING_STARTED_PATH,
|
|
28
29
|
NOTES_REQUIRED_TAGS,
|
|
29
30
|
SURFACE_STARTER_PACK,
|
|
30
31
|
SURFACE_STARTER_PATH,
|
|
31
|
-
|
|
32
|
+
TAGS_GRAPH_PATH,
|
|
32
33
|
WELCOME_PATH,
|
|
34
|
+
YOURS_TO_KEEP_PATH,
|
|
33
35
|
} from "../core/src/seed-packs.ts";
|
|
34
36
|
import {
|
|
35
37
|
buildVaultProjection,
|
|
36
38
|
projectionToMarkdown,
|
|
37
39
|
} from "../core/src/vault-projection.ts";
|
|
38
40
|
|
|
39
|
-
/** Every note path the default seed writes on a blank vault
|
|
41
|
+
/** Every note path the default seed writes on a blank vault (the five-guide
|
|
42
|
+
* welcome ring + the AI-facing Getting Started). */
|
|
40
43
|
const DEFAULT_SEED_PATHS = [
|
|
41
44
|
WELCOME_PATH,
|
|
42
|
-
|
|
45
|
+
CAPTURE_ANYTHING_PATH,
|
|
46
|
+
TAGS_GRAPH_PATH,
|
|
43
47
|
CONNECT_AI_PATH,
|
|
48
|
+
YOURS_TO_KEEP_PATH,
|
|
44
49
|
GETTING_STARTED_PATH,
|
|
45
50
|
];
|
|
46
51
|
|
|
@@ -77,18 +82,30 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
|
|
|
77
82
|
expect(gs!.content).toContain("Tags = types");
|
|
78
83
|
expect(gs!.content).toContain("Paths = organization");
|
|
79
84
|
expect(gs!.content).toContain("Schemas = typed metadata fields");
|
|
80
|
-
|
|
85
|
+
// Data-in leads with the import UI + MCP bridge; the old `parachute-vault
|
|
86
|
+
// import` CLI line (a live bug on cloud) is gone (2026-07-06 rewrite).
|
|
87
|
+
expect(gs!.content).not.toContain("parachute-vault import");
|
|
88
|
+
expect(gs!.content).toContain("Bringing existing notes in");
|
|
81
89
|
expect(gs!.content).toContain("Adapt this note");
|
|
82
90
|
|
|
83
|
-
// The welcome
|
|
84
|
-
for (const path of [
|
|
91
|
+
// The welcome ring: five person-voiced guide notes.
|
|
92
|
+
for (const path of [
|
|
93
|
+
WELCOME_PATH,
|
|
94
|
+
CAPTURE_ANYTHING_PATH,
|
|
95
|
+
TAGS_GRAPH_PATH,
|
|
96
|
+
CONNECT_AI_PATH,
|
|
97
|
+
YOURS_TO_KEEP_PATH,
|
|
98
|
+
]) {
|
|
85
99
|
expect(await store.getNoteByPath(path)).not.toBeNull();
|
|
86
100
|
}
|
|
87
101
|
});
|
|
88
102
|
|
|
89
|
-
test("seeds the
|
|
103
|
+
test("seeds the capture tag Notes requires (byte-equal semantics) + the guide tag", async () => {
|
|
90
104
|
const result = await seedOnboardingNotes(store);
|
|
91
|
-
|
|
105
|
+
// welcome upserts [capture, guide]; getting-started re-upserts [guide]
|
|
106
|
+
// (converges). applySeedPack reports every declared tag per pack, so the
|
|
107
|
+
// concatenation carries guide twice — deterministic.
|
|
108
|
+
expect(result.tags).toEqual(["capture", "guide", "guide"]);
|
|
92
109
|
|
|
93
110
|
for (const decl of NOTES_REQUIRED_TAGS) {
|
|
94
111
|
const record = await store.getTagRecord(decl.name);
|
|
@@ -97,6 +114,15 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
|
|
|
97
114
|
expect(record!.parent_names ?? []).toEqual(decl.parent_names ?? []);
|
|
98
115
|
}
|
|
99
116
|
|
|
117
|
+
// The guide tag lands with no metadata schema — guides are for you and your
|
|
118
|
+
// AI alike, plain markdown, so no per-guide audience field (written_for dropped).
|
|
119
|
+
const guide = await store.getTagRecord("guide");
|
|
120
|
+
expect(guide).not.toBeNull();
|
|
121
|
+
expect(guide!.fields?.written_for).toBeUndefined();
|
|
122
|
+
// #pinned is NOT seeded (dropped 2026-07-07 — did nothing visible on a fresh
|
|
123
|
+
// vault). Pinning still works when the user pins a note in the app.
|
|
124
|
+
expect(await store.getTagRecord("pinned")).toBeNull();
|
|
125
|
+
|
|
100
126
|
// The retired subtype tags are NOT seeded — entry method is note
|
|
101
127
|
// metadata.source (text|voice), not taxonomy (2026-07-03). Existing
|
|
102
128
|
// vaults carrying these tags stay valid; new vaults don't get them.
|
|
@@ -104,6 +130,29 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
|
|
|
104
130
|
expect(await store.getTagRecord("capture/voice")).toBeNull();
|
|
105
131
|
});
|
|
106
132
|
|
|
133
|
+
test("seeded guides carry #guide (create-time tag write-through)", async () => {
|
|
134
|
+
await seedOnboardingNotes(store);
|
|
135
|
+
|
|
136
|
+
// Welcome guide: tagged guide (no longer pinned).
|
|
137
|
+
const welcome = await store.getNoteByPath(WELCOME_PATH);
|
|
138
|
+
expect([...(welcome!.tags ?? [])].sort()).toEqual(["guide"]);
|
|
139
|
+
|
|
140
|
+
// The four sibling guides: guide only.
|
|
141
|
+
for (const path of [
|
|
142
|
+
CAPTURE_ANYTHING_PATH,
|
|
143
|
+
TAGS_GRAPH_PATH,
|
|
144
|
+
CONNECT_AI_PATH,
|
|
145
|
+
YOURS_TO_KEEP_PATH,
|
|
146
|
+
]) {
|
|
147
|
+
const note = await store.getNoteByPath(path);
|
|
148
|
+
expect(note!.tags).toEqual(["guide"]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The AI-facing Getting Started guide: guide.
|
|
152
|
+
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
153
|
+
expect(gs!.tags).toEqual(["guide"]);
|
|
154
|
+
});
|
|
155
|
+
|
|
107
156
|
test("GAP 4: Getting Started shows a concrete update-tag fields example + write gotchas", async () => {
|
|
108
157
|
await seedOnboardingNotes(store);
|
|
109
158
|
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
@@ -121,26 +170,38 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
|
|
|
121
170
|
expect(gs!.content).toContain("first listed value");
|
|
122
171
|
});
|
|
123
172
|
|
|
124
|
-
test("A3 (reshaped): the welcome
|
|
173
|
+
test("A3 (reshaped): the welcome ring resolves to real link edges; Getting Started has no dangling Surface Starter link", async () => {
|
|
125
174
|
await seedOnboardingNotes(store);
|
|
126
175
|
|
|
127
|
-
// welcome → try-linking, try-linking → welcome, connect-AI → welcome.
|
|
128
176
|
const welcome = await store.getNoteByPath(WELCOME_PATH);
|
|
129
|
-
const
|
|
177
|
+
const capture = await store.getNoteByPath(CAPTURE_ANYTHING_PATH);
|
|
178
|
+
const tags = await store.getNoteByPath(TAGS_GRAPH_PATH);
|
|
130
179
|
const connectAi = await store.getNoteByPath(CONNECT_AI_PATH);
|
|
180
|
+
const yours = await store.getNoteByPath(YOURS_TO_KEEP_PATH);
|
|
181
|
+
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
131
182
|
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
183
|
+
const outbound = async (id: string) =>
|
|
184
|
+
(await store.getLinks(id, { direction: "outbound" })).map((l) => l.targetId);
|
|
185
|
+
|
|
186
|
+
// Welcome → all four siblings.
|
|
187
|
+
const welcomeOut = await outbound(welcome!.id);
|
|
188
|
+
for (const t of [capture!.id, tags!.id, connectAi!.id, yours!.id]) {
|
|
189
|
+
expect(welcomeOut).toContain(t);
|
|
190
|
+
}
|
|
191
|
+
// The chain forward: capture → tags → connect → yours → welcome.
|
|
192
|
+
expect(await outbound(capture!.id)).toContain(tags!.id);
|
|
193
|
+
expect(await outbound(tags!.id)).toContain(connectAi!.id);
|
|
194
|
+
const connectOut = await outbound(connectAi!.id);
|
|
195
|
+
expect(connectOut).toContain(yours!.id);
|
|
196
|
+
// Connect your AI also links the AI-facing Getting Started note (seeded by
|
|
197
|
+
// the getting-started pack) — the ring's cross-link resolves, no dangle.
|
|
198
|
+
expect(connectOut).toContain(gs!.id);
|
|
199
|
+
expect(await outbound(yours!.id)).toContain(welcome!.id);
|
|
138
200
|
|
|
139
201
|
// Getting Started mentions the surface-starter PACK — not a wikilink to a
|
|
140
202
|
// note that the default seed no longer creates.
|
|
141
|
-
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
142
203
|
expect(gs!.content).not.toContain("[[Surface Starter]]");
|
|
143
|
-
expect(gs!.content).toContain("add-pack
|
|
204
|
+
expect(gs!.content).toContain("add-pack");
|
|
144
205
|
});
|
|
145
206
|
|
|
146
207
|
test("idempotent: a second seed run skips all notes (does not duplicate)", async () => {
|
|
@@ -179,7 +240,8 @@ describe("applySeedPack — surface-starter via add-pack", () => {
|
|
|
179
240
|
expect(result.pack).toBe("surface-starter");
|
|
180
241
|
expect(result.seededNotes).toEqual([SURFACE_STARTER_PATH]);
|
|
181
242
|
expect(result.skippedNotes).toEqual([]);
|
|
182
|
-
|
|
243
|
+
// surface-starter now declares GUIDE_TAG (it's a guide).
|
|
244
|
+
expect(result.tags).toEqual(["guide"]);
|
|
183
245
|
|
|
184
246
|
const ss = await store.getNoteByPath(SURFACE_STARTER_PATH);
|
|
185
247
|
expect(ss).not.toBeNull();
|
package/src/onboarding-seed.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Seed the default packs on vault creation.
|
|
3
3
|
*
|
|
4
|
-
* A freshly-created vault gets the `welcome` pack (the
|
|
5
|
-
*
|
|
4
|
+
* A freshly-created vault gets the `welcome` pack (the five-guide welcome ring
|
|
5
|
+
* + the `capture` / `guide` / `pinned` tags the Notes surface expects) and
|
|
6
6
|
* the `getting-started` pack (the AI-legible start-here doctrine) — so the
|
|
7
7
|
* first minute shows a small living graph, and a connected AI can read the
|
|
8
8
|
* guide and help the operator set the vault up.
|
package/src/routes.ts
CHANGED
|
@@ -2089,7 +2089,7 @@ export async function handleTags(
|
|
|
2089
2089
|
|
|
2090
2090
|
// PUT /tags/:name — upsert tag identity row. Body accepts any combination
|
|
2091
2091
|
// of { description, fields, relationships, parent_names }; omitted keys
|
|
2092
|
-
// are preserved, explicit null clears. See
|
|
2092
|
+
// are preserved, explicit null clears. See docs/contracts/tag-data-model.md.
|
|
2093
2093
|
if (req.method === "PUT") {
|
|
2094
2094
|
// Canonical-bare-tag guard (vault#XXX): normalize the upserted tag NAME so
|
|
2095
2095
|
// the existing-field merge read (store.getTagSchema below) and the upsert
|
|
@@ -2114,7 +2114,7 @@ export async function handleTags(
|
|
|
2114
2114
|
* the MCP `update-tag` tool relies on (omitted keys preserved). The
|
|
2115
2115
|
* Schema editor (vault#283) sends the full map + `replace_fields: true`
|
|
2116
2116
|
* so removing a field row actually deletes the field. See
|
|
2117
|
-
*
|
|
2117
|
+
* docs/contracts/tag-data-model.md.
|
|
2118
2118
|
*/
|
|
2119
2119
|
replace_fields?: unknown;
|
|
2120
2120
|
};
|
|
@@ -2211,7 +2211,7 @@ export async function handleTags(
|
|
|
2211
2211
|
// Tag-scoped tokens reference root tags by name; deleting a referenced
|
|
2212
2212
|
// tag would silently orphan the token's allowlist. Fail closed (409)
|
|
2213
2213
|
// and name the offending tokens so the operator can revoke or re-mint
|
|
2214
|
-
// before retrying.
|
|
2214
|
+
// before retrying. docs/contracts/tag-scoped-tokens.md §Dependencies.
|
|
2215
2215
|
const referenced_by = findTokensReferencingTag(store.db, tagName);
|
|
2216
2216
|
if (referenced_by.length > 0) {
|
|
2217
2217
|
return json(
|
package/src/routing.test.ts
CHANGED
|
@@ -1631,7 +1631,7 @@ describe("scope enforcement on /api/*", () => {
|
|
|
1631
1631
|
expect(res.status).toBe(401);
|
|
1632
1632
|
});
|
|
1633
1633
|
|
|
1634
|
-
// ----- tag-scoped tokens (
|
|
1634
|
+
// ----- tag-scoped tokens (docs/contracts/tag-scoped-tokens.md) -----------------
|
|
1635
1635
|
|
|
1636
1636
|
/**
|
|
1637
1637
|
* Mint a tag-scoped token. Mirrors `mintToken` above but threads
|
|
@@ -1745,7 +1745,7 @@ describe("scope enforcement on /api/*", () => {
|
|
|
1745
1745
|
});
|
|
1746
1746
|
|
|
1747
1747
|
// ----- Q6: orphan-sub-tag fail-open ------------------------------------
|
|
1748
|
-
//
|
|
1748
|
+
// docs/contracts/tag-scoped-tokens.md §Storage: when a sub-tag has no declared
|
|
1749
1749
|
// schema, the string-form root authorizes. Token allowlisted for `health`
|
|
1750
1750
|
// must see `#health/food` even when no `_tags/health/food` schema exists.
|
|
1751
1751
|
|
package/src/routing.ts
CHANGED
|
@@ -73,7 +73,6 @@ import {
|
|
|
73
73
|
type TagScopeCtx,
|
|
74
74
|
type WriteCtx,
|
|
75
75
|
} from "./routes.ts";
|
|
76
|
-
import { handleSubscribe } from "./subscribe.ts";
|
|
77
76
|
import { handleTriggers } from "./triggers-api.ts";
|
|
78
77
|
import { expandTokenTagScope } from "./tag-scope.ts";
|
|
79
78
|
import {
|
|
@@ -856,7 +855,7 @@ export async function route(
|
|
|
856
855
|
|
|
857
856
|
const apiPath = apiMatch[1] ?? "";
|
|
858
857
|
|
|
859
|
-
// Tag-scoped tokens (
|
|
858
|
+
// Tag-scoped tokens (docs/contracts/tag-scoped-tokens.md): expand the token's
|
|
860
859
|
// root-tag allowlist into `{root} ∪ descendants(root)` once per request,
|
|
861
860
|
// so handlers can intersect against the note's tag set without re-walking
|
|
862
861
|
// the `_tags/<name>` hierarchy on every check. `tagScope.allowed` is null
|
|
@@ -883,10 +882,23 @@ export async function route(
|
|
|
883
882
|
};
|
|
884
883
|
|
|
885
884
|
if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope, writeCtx);
|
|
886
|
-
// Live-query SSE
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
|
|
885
|
+
// Live-query SSE transport REMOVED (WS-hibernation migration Phase 5). The
|
|
886
|
+
// WebSocket binding (an `Upgrade: websocket` GET /subscribe, handled in
|
|
887
|
+
// server.ts before this pipeline) is now the SOLE live transport; polling
|
|
888
|
+
// GET /notes is the client floor beneath it. A non-WS GET /subscribe — a
|
|
889
|
+
// straggler on a cached pre-WS notes-ui bundle — gets a clean 410 Gone
|
|
890
|
+
// pointing at the WS binding so its consumer degrades to polling gracefully;
|
|
891
|
+
// NEVER a 500 or an unhandled path.
|
|
892
|
+
if (apiPath === "/subscribe") {
|
|
893
|
+
return Response.json(
|
|
894
|
+
{
|
|
895
|
+
error:
|
|
896
|
+
"The live-query SSE transport has been removed. Reconnect with an `Upgrade: websocket` request to GET /api/subscribe, or poll GET /notes.",
|
|
897
|
+
code: "SSE_TRANSPORT_REMOVED",
|
|
898
|
+
},
|
|
899
|
+
{ status: 410 },
|
|
900
|
+
);
|
|
901
|
+
}
|
|
890
902
|
if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5), tagScope);
|
|
891
903
|
if (apiPath === "/find-path") return handleFindPath(req, store, tagScope);
|
|
892
904
|
if (apiPath === "/vault") {
|
|
@@ -382,6 +382,25 @@ describe("self-register", () => {
|
|
|
382
382
|
});
|
|
383
383
|
});
|
|
384
384
|
|
|
385
|
+
test("websocket capability flows from manifest to row when set (hub ws-bridge gate)", () => {
|
|
386
|
+
withParachuteHome((home) => {
|
|
387
|
+
const { log, warn } = captureLogs();
|
|
388
|
+
selfRegister({
|
|
389
|
+
version: "0.4.8-rc.3",
|
|
390
|
+
log,
|
|
391
|
+
warn,
|
|
392
|
+
readManifest: () => ({ ...TEST_MANIFEST, websocket: true }),
|
|
393
|
+
resolvePackageRoot: () => "/fake/install/dir",
|
|
394
|
+
listVaults: () => [],
|
|
395
|
+
readGlobalConfig: () => ({ port: 1940 }),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const raw = readFileSync(join(home, "services.json"), "utf8");
|
|
399
|
+
const parsed = JSON.parse(raw) as { services: Record<string, unknown>[] };
|
|
400
|
+
expect(parsed.services[0]!.websocket).toBe(true);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
385
404
|
test("changed=true when installDir differs from prior row", () => {
|
|
386
405
|
withParachuteHome(() => {
|
|
387
406
|
const { log, warn } = captureLogs();
|
package/src/self-register.ts
CHANGED
|
@@ -191,6 +191,7 @@ export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
|
|
|
191
191
|
displayName?: string;
|
|
192
192
|
tagline?: string;
|
|
193
193
|
stripPrefix?: boolean;
|
|
194
|
+
websocket?: boolean;
|
|
194
195
|
} = {
|
|
195
196
|
name: manifest.manifestName,
|
|
196
197
|
port,
|
|
@@ -202,6 +203,9 @@ export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
|
|
|
202
203
|
if (manifest.displayName !== undefined) entry.displayName = manifest.displayName;
|
|
203
204
|
if (manifest.tagline !== undefined) entry.tagline = manifest.tagline;
|
|
204
205
|
if (manifest.stripPrefix !== undefined) entry.stripPrefix = manifest.stripPrefix;
|
|
206
|
+
// Carry the live-query WS capability onto the row so the hub ws-bridge admits
|
|
207
|
+
// upgrades without having to read module.json (the row is its first source).
|
|
208
|
+
if (manifest.websocket !== undefined) entry.websocket = manifest.websocket;
|
|
205
209
|
|
|
206
210
|
// Detect whether the existing row already matches (no-op idempotency
|
|
207
211
|
// signal). We don't gate the write on this — `upsertService` itself is
|
|
@@ -232,7 +236,8 @@ export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
|
|
|
232
236
|
priorRow.health !== health ||
|
|
233
237
|
(priorRow as { displayName?: string }).displayName !== manifest.displayName ||
|
|
234
238
|
(priorRow as { tagline?: string }).tagline !== manifest.tagline ||
|
|
235
|
-
(priorRow as { stripPrefix?: boolean }).stripPrefix !== manifest.stripPrefix
|
|
239
|
+
(priorRow as { stripPrefix?: boolean }).stripPrefix !== manifest.stripPrefix ||
|
|
240
|
+
(priorRow as { websocket?: boolean }).websocket !== manifest.websocket;
|
|
236
241
|
|
|
237
242
|
try {
|
|
238
243
|
upsertImpl(entry);
|
package/src/server.ts
CHANGED
|
@@ -31,10 +31,18 @@ import { assetsDir } from "./routes.ts";
|
|
|
31
31
|
import { resolveScribeAuthToken, ensureScribeBearer } from "./scribe-env.ts";
|
|
32
32
|
import { getCachedScribeUrl } from "./scribe-discovery.ts";
|
|
33
33
|
import { TranscribeCppProvider } from "./transcription/providers/transcribe-cpp.ts";
|
|
34
|
+
import { ParakeetMlxProvider } from "./transcription/providers/parakeet-mlx.ts";
|
|
35
|
+
import { OnnxAsrProvider } from "./transcription/providers/onnx-asr.ts";
|
|
34
36
|
import {
|
|
35
37
|
resolveTranscriptionProviderName,
|
|
36
38
|
resolveTranscribeCppPaths,
|
|
37
39
|
transcribeCppInstalled,
|
|
40
|
+
resolveParakeetMlxBin,
|
|
41
|
+
resolveParakeetMlxModel,
|
|
42
|
+
resolveOnnxAsrBin,
|
|
43
|
+
resolveOnnxAsrModel,
|
|
44
|
+
parakeetMlxInstalled,
|
|
45
|
+
onnxAsrInstalled,
|
|
38
46
|
} from "./transcription/select.ts";
|
|
39
47
|
import { readEnvFile, setEnvVar } from "./config.ts";
|
|
40
48
|
import { resolveBindHostname } from "./bind.ts";
|
|
@@ -53,6 +61,7 @@ import {
|
|
|
53
61
|
} from "./mirror-config.ts";
|
|
54
62
|
import { GLOBAL_CONFIG_PATH } from "./config.ts";
|
|
55
63
|
import { selfRegister } from "./self-register.ts";
|
|
64
|
+
import { createSubscribeWsBinding, isWebSocketUpgrade } from "./ws-server.ts";
|
|
56
65
|
import { warnLegacyGlobalApiKeys } from "./auth.ts";
|
|
57
66
|
import pkg from "../package.json" with { type: "json" };
|
|
58
67
|
|
|
@@ -158,6 +167,29 @@ if (providerName === "transcribe-cpp") {
|
|
|
158
167
|
"[transcribe] TRANSCRIPTION_PROVIDER=transcribe-cpp but no runnable transcribe-cli + model installed — see `parachute-vault transcription status` (v0.1.1 ships a library, not a CLI; set TRANSCRIBE_CPP_BIN or build one)",
|
|
159
168
|
);
|
|
160
169
|
}
|
|
170
|
+
} else if (providerName === "parakeet-mlx" || providerName === "onnx-asr") {
|
|
171
|
+
// Python-based local providers (scribe-fold Phase 2b): subprocess the
|
|
172
|
+
// parakeet-mlx / onnx-asr CLI. Only start the worker when a runnable binary
|
|
173
|
+
// resolves (env override → managed venv → PATH) — otherwise every pending
|
|
174
|
+
// item would terminal-fail with `missing_provider`. Cheap existsSync check
|
|
175
|
+
// (no spawn), matching the providers' `available()`.
|
|
176
|
+
const installed = providerName === "parakeet-mlx" ? parakeetMlxInstalled() : onnxAsrInstalled();
|
|
177
|
+
if (installed) {
|
|
178
|
+
const provider =
|
|
179
|
+
providerName === "parakeet-mlx"
|
|
180
|
+
? new ParakeetMlxProvider({
|
|
181
|
+
binPath: resolveParakeetMlxBin(),
|
|
182
|
+
model: resolveParakeetMlxModel(),
|
|
183
|
+
})
|
|
184
|
+
: new OnnxAsrProvider({ binPath: resolveOnnxAsrBin(), model: resolveOnnxAsrModel() });
|
|
185
|
+
transcriptionWorker = startTranscriptionWorker({ ...commonWorkerOpts, provider });
|
|
186
|
+
wireTranscriptionWorker(transcriptionWorker);
|
|
187
|
+
console.log(`[transcribe] worker started → ${providerName}`);
|
|
188
|
+
} else {
|
|
189
|
+
console.log(
|
|
190
|
+
`[transcribe] TRANSCRIPTION_PROVIDER=${providerName} but no runnable ${providerName} binary found — run \`parachute-vault transcription install\` (see \`transcription status\`)`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
161
193
|
} else {
|
|
162
194
|
// Default scribe-http path — behavior-preserving (Phase 1). Start the worker
|
|
163
195
|
// if scribe is discoverable; otherwise transcription is a no-op.
|
|
@@ -459,10 +491,18 @@ const hostname = resolveBindHostname();
|
|
|
459
491
|
}
|
|
460
492
|
}
|
|
461
493
|
|
|
494
|
+
// Live-query WebSocket binding (WS-hibernation migration Phase 3). Same
|
|
495
|
+
// subscription contract as the SSE route, WebSocket transport. Handlers +
|
|
496
|
+
// upgrade decision live in ws-server.ts; the per-vault WS cap is closure state
|
|
497
|
+
// there. Advertised via `"websocket": true` in `.parachute/module.json` so the
|
|
498
|
+
// hub ws-bridge admits the upgrade.
|
|
499
|
+
const subscribeWs = createSubscribeWsBinding();
|
|
500
|
+
|
|
462
501
|
const server = Bun.serve({
|
|
463
502
|
port,
|
|
464
503
|
hostname,
|
|
465
504
|
idleTimeout: 120, // seconds — webhook triggers can take a while
|
|
505
|
+
websocket: subscribeWs.handlers,
|
|
466
506
|
async fetch(req, server) {
|
|
467
507
|
const url = new URL(req.url);
|
|
468
508
|
const path = url.pathname;
|
|
@@ -486,6 +526,22 @@ const server = Bun.serve({
|
|
|
486
526
|
return new Response(null, { status: 204, headers: corsHeaders });
|
|
487
527
|
}
|
|
488
528
|
|
|
529
|
+
// Live-query WebSocket upgrade — detected BEFORE the fetch pipeline because
|
|
530
|
+
// WS upgrades don't traverse `route()`. Non-upgrade requests (incl. a
|
|
531
|
+
// straggler non-WS GET /subscribe, which now gets a 410 Gone in routing.ts —
|
|
532
|
+
// the SSE transport was removed in Phase 5) fall straight through unchanged.
|
|
533
|
+
if (isWebSocketUpgrade(req)) {
|
|
534
|
+
const verdict = subscribeWs.tryUpgrade(req, server, path);
|
|
535
|
+
if (verdict.kind === "upgraded") return undefined; // Bun owns the socket now
|
|
536
|
+
if (verdict.kind === "response") {
|
|
537
|
+
for (const [k, v] of Object.entries(corsHeaders)) {
|
|
538
|
+
verdict.response.headers.set(k, v);
|
|
539
|
+
}
|
|
540
|
+
return verdict.response;
|
|
541
|
+
}
|
|
542
|
+
// "pass" → not a subscribe WS upgrade; fall through to the pipeline.
|
|
543
|
+
}
|
|
544
|
+
|
|
489
545
|
try {
|
|
490
546
|
const start = Date.now();
|
|
491
547
|
const response = await route(req, path);
|