@openparachute/vault 0.4.7-rc.2 → 0.4.8-rc.4
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/README.md +44 -10
- package/core/src/connection-pragmas.test.ts +232 -0
- package/core/src/core.test.ts +257 -0
- package/core/src/cursor.test.ts +160 -0
- package/core/src/cursor.ts +272 -0
- package/core/src/mcp.ts +51 -7
- package/core/src/notes.ts +164 -2
- package/core/src/schema.ts +98 -2
- package/core/src/store.ts +11 -1
- package/core/src/types.ts +32 -0
- package/package.json +1 -1
- package/src/auth-status.ts +4 -0
- package/src/auto-transcribe.test.ts +116 -0
- package/src/auto-transcribe.ts +48 -0
- package/src/cli.ts +57 -48
- package/src/config.test.ts +26 -0
- package/src/config.ts +53 -1
- package/src/db.ts +15 -2
- package/src/mcp-install-interactive.test.ts +23 -2
- package/src/mcp-install-interactive.ts +21 -2
- package/src/mcp-install.test.ts +40 -0
- package/src/mcp-tools.ts +17 -1
- package/src/module-config.ts +70 -14
- package/src/module-manifest.test.ts +93 -0
- package/src/module-manifest.ts +94 -0
- package/src/routes.ts +267 -50
- package/src/scribe-discovery.test.ts +77 -0
- package/src/scribe-discovery.ts +91 -0
- package/src/scribe-env.test.ts +66 -1
- package/src/scribe-env.ts +42 -1
- package/src/self-register.test.ts +380 -0
- package/src/self-register.ts +234 -0
- package/src/server.ts +46 -11
- package/src/transcript-note.test.ts +171 -0
- package/src/transcript-note.ts +189 -0
- package/src/transcription-registry.ts +22 -0
- package/src/transcription-worker.test.ts +250 -0
- package/src/transcription-worker.ts +186 -27
- package/src/vault.test.ts +347 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot-time self-registration of vault's manifest + `installDir` into
|
|
3
|
+
* `~/.parachute/services.json` — the POC for retiring hub's
|
|
4
|
+
* `FIRST_PARTY_FALLBACKS[vault]` (vault#266).
|
|
5
|
+
*
|
|
6
|
+
* Background: hub today vendors a `VAULT_FALLBACK` manifest in
|
|
7
|
+
* `parachute-hub/src/service-spec.ts` and stamps `installDir` onto the
|
|
8
|
+
* services.json row via `stampInstallDirOnRow` during `parachute install
|
|
9
|
+
* vault` / API install. That fallback exists because (a) bun-link dev
|
|
10
|
+
* mode never runs the hub install path so installDir isn't stamped,
|
|
11
|
+
* and (b) v0.5 vault didn't ship its own `.parachute/module.json` so
|
|
12
|
+
* hub had no manifest to read at lifecycle time.
|
|
13
|
+
*
|
|
14
|
+
* The endgame: every first-party module self-registers its manifest +
|
|
15
|
+
* installDir on startup so hub's vendored fallbacks retire one by one.
|
|
16
|
+
* This module is vault's piece of that pattern. Once vault/notes/scribe/
|
|
17
|
+
* runner all self-register reliably, a hub follow-up deletes
|
|
18
|
+
* `FIRST_PARTY_FALLBACKS` (see `parachute-hub` for the cleanup PR).
|
|
19
|
+
*
|
|
20
|
+
* Design choice — filesystem-direct rather than HTTP:
|
|
21
|
+
*
|
|
22
|
+
* In v0.6 (single-container, hub-as-supervisor — see workspace
|
|
23
|
+
* CLAUDE.md), hub and vault share the same filesystem. Writing directly
|
|
24
|
+
* to services.json with the existing merge-preserving `upsertService`
|
|
25
|
+
* is the simplest shape that works today. The hub-stamped fields the
|
|
26
|
+
* row already carries (`installDir`, anything else hub adds in the
|
|
27
|
+
* future) ride through because `upsertService` merges rather than
|
|
28
|
+
* replaces (see `services-manifest.ts`).
|
|
29
|
+
*
|
|
30
|
+
* v0.7 (multi-container cloud) will need an HTTP `POST
|
|
31
|
+
* /api/modules/self-register` on hub so a module on a different
|
|
32
|
+
* container can register without filesystem access to the operator's
|
|
33
|
+
* `~/.parachute/`. Filed as a separate hub follow-up; this module's
|
|
34
|
+
* shape is forward-compatible — `selfRegister` is the single seam that
|
|
35
|
+
* would swap from filesystem to HTTP transport.
|
|
36
|
+
*
|
|
37
|
+
* Failure mode: every error path logs + returns (never throws). A bad
|
|
38
|
+
* registration must not crash server boot — the running vault is more
|
|
39
|
+
* valuable than the discoverability bookkeeping. Symptom of failure is
|
|
40
|
+
* "vault doesn't appear on hub discovery / admin SPA"; the fix is to
|
|
41
|
+
* restart vault or run `parachute install vault` to stamp the row via
|
|
42
|
+
* the hub-side path.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { readSelfManifest, resolvePackageRoot, type VaultModuleManifest } from "./module-manifest.ts";
|
|
46
|
+
import {
|
|
47
|
+
ServicesManifestError,
|
|
48
|
+
type ServiceEntry,
|
|
49
|
+
readManifest,
|
|
50
|
+
upsertService,
|
|
51
|
+
} from "./services-manifest.ts";
|
|
52
|
+
import { listVaults, readGlobalConfig, DEFAULT_PORT } from "./config.ts";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Compute the `paths` array for the parachute-vault services.json entry.
|
|
56
|
+
*
|
|
57
|
+
* Mirrors `buildVaultServicePaths` in `cli.ts` so the self-register pass
|
|
58
|
+
* produces the same multi-vault path advertisement as `parachute-vault
|
|
59
|
+
* init` / `vault create`. With no vaults yet, falls back to the manifest's
|
|
60
|
+
* canonical `paths[0]` so early-boot registration is still well-formed.
|
|
61
|
+
*
|
|
62
|
+
* Exported for tests; not part of the public module surface.
|
|
63
|
+
*/
|
|
64
|
+
export function buildVaultServicePaths(
|
|
65
|
+
defaultVault: string | undefined,
|
|
66
|
+
vaults: readonly string[],
|
|
67
|
+
fallbackFromManifest: readonly string[],
|
|
68
|
+
): string[] {
|
|
69
|
+
if (vaults.length === 0) return [...fallbackFromManifest];
|
|
70
|
+
if (defaultVault && vaults.includes(defaultVault)) {
|
|
71
|
+
return [
|
|
72
|
+
`/vault/${defaultVault}`,
|
|
73
|
+
...vaults.filter((v) => v !== defaultVault).map((v) => `/vault/${v}`),
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
return vaults.map((v) => `/vault/${v}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface SelfRegisterDeps {
|
|
80
|
+
/** Override the manifest reader (tests inject a stub manifest). */
|
|
81
|
+
readManifest?: () => VaultModuleManifest | null;
|
|
82
|
+
/** Override the package-root resolver (tests inject a tmp dir). */
|
|
83
|
+
resolvePackageRoot?: () => string;
|
|
84
|
+
/** Override the services.json reader (tests inject a tmp-file reader). */
|
|
85
|
+
readServicesManifest?: typeof readManifest;
|
|
86
|
+
/** Override the services.json upsert (tests inject a tmp-file writer). */
|
|
87
|
+
upsertService?: typeof upsertService;
|
|
88
|
+
/** Override the vault lister (tests pass a fixed list). */
|
|
89
|
+
listVaults?: typeof listVaults;
|
|
90
|
+
/** Override the global config reader (tests pass a synthetic config). */
|
|
91
|
+
readGlobalConfig?: typeof readGlobalConfig;
|
|
92
|
+
/** Sink for status + warning lines. Production passes a console wrapper. */
|
|
93
|
+
log?: (msg: string) => void;
|
|
94
|
+
warn?: (msg: string) => void;
|
|
95
|
+
/** Vault's runtime version (from `package.json`). Required — no default. */
|
|
96
|
+
version: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Result of a `selfRegister` call, surfaced to callers for observability. */
|
|
100
|
+
export type SelfRegisterResult =
|
|
101
|
+
| { status: "registered"; installDir: string; changed: boolean }
|
|
102
|
+
| { status: "skipped"; reason: string }
|
|
103
|
+
| { status: "failed"; reason: string };
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Self-register vault's manifest + installDir into `~/.parachute/services.json`.
|
|
107
|
+
*
|
|
108
|
+
* Idempotent: re-runs with the same manifest + installDir produce the same
|
|
109
|
+
* row (the `changed` field on the result telegraphs whether the write
|
|
110
|
+
* actually mutated the file).
|
|
111
|
+
*
|
|
112
|
+
* Never throws. Errors (missing manifest, services.json unreadable,
|
|
113
|
+
* filesystem write failure) are logged via `warn` and surfaced as a
|
|
114
|
+
* `failed` / `skipped` result. The caller (server boot) treats failure
|
|
115
|
+
* as non-fatal — vault keeps serving without the row stamp.
|
|
116
|
+
*/
|
|
117
|
+
export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
|
|
118
|
+
const log = deps.log ?? ((m) => console.log(m));
|
|
119
|
+
const warn = deps.warn ?? ((m) => console.warn(m));
|
|
120
|
+
const readManifestImpl = deps.readManifest ?? readSelfManifest;
|
|
121
|
+
const resolveRootImpl = deps.resolvePackageRoot ?? resolvePackageRoot;
|
|
122
|
+
const upsertImpl = deps.upsertService ?? upsertService;
|
|
123
|
+
const listVaultsImpl = deps.listVaults ?? listVaults;
|
|
124
|
+
const readGlobalConfigImpl = deps.readGlobalConfig ?? readGlobalConfig;
|
|
125
|
+
|
|
126
|
+
let manifest: VaultModuleManifest | null;
|
|
127
|
+
try {
|
|
128
|
+
manifest = readManifestImpl();
|
|
129
|
+
} catch (err) {
|
|
130
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
131
|
+
warn(`[self-register] could not read .parachute/module.json: ${msg}`);
|
|
132
|
+
return { status: "failed", reason: msg };
|
|
133
|
+
}
|
|
134
|
+
if (!manifest) {
|
|
135
|
+
log("[self-register] no .parachute/module.json found — skipping (legacy install or dev tree)");
|
|
136
|
+
return { status: "skipped", reason: "manifest absent" };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// `installDir` is the directory containing both `package.json` and
|
|
140
|
+
// `.parachute/module.json`. Hub's resolver (`<installDir>/.parachute/module.json`)
|
|
141
|
+
// expects exactly this shape — see `parachute-hub/src/post-install.ts`'s
|
|
142
|
+
// `stampInstallDirOnRow`.
|
|
143
|
+
let installDir: string;
|
|
144
|
+
try {
|
|
145
|
+
installDir = resolveRootImpl();
|
|
146
|
+
} catch (err) {
|
|
147
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
148
|
+
warn(`[self-register] could not resolve package root: ${msg}`);
|
|
149
|
+
return { status: "failed", reason: msg };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let globalConfig: ReturnType<typeof readGlobalConfig>;
|
|
153
|
+
let vaults: string[];
|
|
154
|
+
try {
|
|
155
|
+
globalConfig = readGlobalConfigImpl();
|
|
156
|
+
vaults = listVaultsImpl();
|
|
157
|
+
} catch (err) {
|
|
158
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
159
|
+
warn(`[self-register] could not read vault config: ${msg}`);
|
|
160
|
+
return { status: "failed", reason: msg };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const paths = buildVaultServicePaths(globalConfig.default_vault, vaults, manifest.paths);
|
|
164
|
+
const port = globalConfig.port ?? DEFAULT_PORT;
|
|
165
|
+
|
|
166
|
+
// Build the entry with manifest-sourced metadata (displayName, tagline,
|
|
167
|
+
// stripPrefix) layered on top of the operationally-determined fields
|
|
168
|
+
// (port from config, paths from current vault list, version from
|
|
169
|
+
// package.json). hub-stamped fields on the existing row (installDir
|
|
170
|
+
// from prior CLI install path, anything future) survive via
|
|
171
|
+
// `upsertService`'s merge semantics — see services-manifest.ts.
|
|
172
|
+
const entry: ServiceEntry & {
|
|
173
|
+
installDir: string;
|
|
174
|
+
displayName?: string;
|
|
175
|
+
tagline?: string;
|
|
176
|
+
stripPrefix?: boolean;
|
|
177
|
+
} = {
|
|
178
|
+
name: manifest.manifestName,
|
|
179
|
+
port,
|
|
180
|
+
paths,
|
|
181
|
+
health: manifest.health,
|
|
182
|
+
version: deps.version,
|
|
183
|
+
installDir,
|
|
184
|
+
};
|
|
185
|
+
if (manifest.displayName !== undefined) entry.displayName = manifest.displayName;
|
|
186
|
+
if (manifest.tagline !== undefined) entry.tagline = manifest.tagline;
|
|
187
|
+
if (manifest.stripPrefix !== undefined) entry.stripPrefix = manifest.stripPrefix;
|
|
188
|
+
|
|
189
|
+
// Detect whether the existing row already matches (no-op idempotency
|
|
190
|
+
// signal). We don't gate the write on this — `upsertService` itself is
|
|
191
|
+
// already idempotent at the byte level — but reporting `changed: false`
|
|
192
|
+
// lets the boot log say "already registered" instead of restamping noise.
|
|
193
|
+
let priorRow: (ServiceEntry & { installDir?: string }) | undefined;
|
|
194
|
+
try {
|
|
195
|
+
const current = (deps.readServicesManifest ?? readManifest)();
|
|
196
|
+
priorRow = current.services.find((s) => s.name === manifest.manifestName) as
|
|
197
|
+
| (ServiceEntry & { installDir?: string })
|
|
198
|
+
| undefined;
|
|
199
|
+
} catch (err) {
|
|
200
|
+
// Read failure here is non-fatal — we'll still attempt the write below.
|
|
201
|
+
// services.json may not exist yet (fresh boot); upsertService creates it.
|
|
202
|
+
if (err instanceof ServicesManifestError) {
|
|
203
|
+
warn(`[self-register] services.json read warning: ${err.message}`);
|
|
204
|
+
} else {
|
|
205
|
+
warn(`[self-register] services.json read warning: ${String(err)}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const changed =
|
|
210
|
+
!priorRow ||
|
|
211
|
+
priorRow.installDir !== installDir ||
|
|
212
|
+
priorRow.version !== deps.version ||
|
|
213
|
+
priorRow.port !== port ||
|
|
214
|
+
JSON.stringify(priorRow.paths) !== JSON.stringify(paths) ||
|
|
215
|
+
priorRow.health !== manifest.health ||
|
|
216
|
+
(priorRow as { displayName?: string }).displayName !== manifest.displayName ||
|
|
217
|
+
(priorRow as { tagline?: string }).tagline !== manifest.tagline ||
|
|
218
|
+
(priorRow as { stripPrefix?: boolean }).stripPrefix !== manifest.stripPrefix;
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
upsertImpl(entry);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
224
|
+
warn(`[self-register] services.json write failed: ${msg}`);
|
|
225
|
+
return { status: "failed", reason: msg };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (changed) {
|
|
229
|
+
log(`[self-register] registered ${manifest.manifestName} (installDir=${installDir})`);
|
|
230
|
+
} else {
|
|
231
|
+
log(`[self-register] already registered ${manifest.manifestName} (no changes)`);
|
|
232
|
+
}
|
|
233
|
+
return { status: "registered", installDir, changed };
|
|
234
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -24,12 +24,17 @@ import { defaultHookRegistry } from "../core/src/hooks.ts";
|
|
|
24
24
|
import { registerTriggers } from "./triggers.ts";
|
|
25
25
|
import { route } from "./routing.ts";
|
|
26
26
|
import { startTranscriptionWorker, registerTranscriptionHook, type TranscriptionWorker } from "./transcription-worker.ts";
|
|
27
|
+
import { setTranscriptionWorker } from "./transcription-registry.ts";
|
|
27
28
|
import { assetsDir } from "./routes.ts";
|
|
28
|
-
import { resolveScribeAuthToken } from "./scribe-env.ts";
|
|
29
|
+
import { resolveScribeAuthToken, ensureScribeBearer } from "./scribe-env.ts";
|
|
30
|
+
import { getCachedScribeUrl } from "./scribe-discovery.ts";
|
|
31
|
+
import { readEnvFile, setEnvVar } from "./config.ts";
|
|
29
32
|
import { resolveBindHostname } from "./bind.ts";
|
|
30
33
|
import { MirrorManager } from "./mirror-manager.ts";
|
|
31
34
|
import { setMirrorManager } from "./mirror-registry.ts";
|
|
32
35
|
import { buildMirrorDeps, resolveMirrorVaultName } from "./mirror-deps.ts";
|
|
36
|
+
import { selfRegister } from "./self-register.ts";
|
|
37
|
+
import pkg from "../package.json" with { type: "json" };
|
|
33
38
|
|
|
34
39
|
// Register webhook triggers from global config. Replaces the old hardcoded
|
|
35
40
|
// tts-hook and transcription-hook with config-driven webhooks.
|
|
@@ -47,8 +52,9 @@ function registerConfiguredTriggers(): void {
|
|
|
47
52
|
// both will process the same attachments. The trigger's `missing_metadata`
|
|
48
53
|
// guard keeps it idempotent once the worker marks `transcript` on the
|
|
49
54
|
// attachment, but the noise is worth flagging.
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
const probedScribeUrl = getCachedScribeUrl();
|
|
56
|
+
if (probedScribeUrl) {
|
|
57
|
+
const scribeHost = safeHost(probedScribeUrl);
|
|
52
58
|
for (const t of config.triggers) {
|
|
53
59
|
if (t.action.send !== "attachment") continue;
|
|
54
60
|
if (scribeHost && safeHost(t.action.webhook) === scribeHost) {
|
|
@@ -74,17 +80,35 @@ loadEnvFile();
|
|
|
74
80
|
registerConfiguredTriggers();
|
|
75
81
|
|
|
76
82
|
/**
|
|
77
|
-
* Start the transcription worker if
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
83
|
+
* Start the transcription worker if scribe is discoverable. Scribe URL
|
|
84
|
+
* resolution order (per `scribe-discovery.ts`): `SCRIBE_URL` env var, then
|
|
85
|
+
* `~/.parachute/services.json` `parachute-scribe` entry, then nothing.
|
|
86
|
+
*
|
|
87
|
+
* Bearer generation (vault#353): if neither `SCRIBE_AUTH_TOKEN` nor the
|
|
88
|
+
* legacy `SCRIBE_TOKEN` is set, generate a fresh 32-byte base64url bearer
|
|
89
|
+
* and persist it to vault's `.env` so subsequent restarts use the same
|
|
90
|
+
* value. Idempotent — calls after the first see the existing token. The
|
|
91
|
+
* operator (or hub install) is expected to mirror this bearer to scribe's
|
|
92
|
+
* `SCRIBE_AUTH_TOKEN`; without that mirror, scribe will reject the
|
|
93
|
+
* Authorization header on a 401 and transcription fails with a friendly
|
|
94
|
+
* error captured on the transcript note.
|
|
81
95
|
*/
|
|
96
|
+
const scribeUrl = getCachedScribeUrl();
|
|
82
97
|
let transcriptionWorker: TranscriptionWorker | null = null;
|
|
83
|
-
if (
|
|
98
|
+
if (scribeUrl) {
|
|
99
|
+
// Generate + persist the shared bearer on first boot. Subsequent boots
|
|
100
|
+
// pick up the existing value and don't rotate. Loading the .env back
|
|
101
|
+
// into process.env happens above (`loadEnvFile()`); we re-load here to
|
|
102
|
+
// pick up the just-written value without restart.
|
|
103
|
+
const { created, token } = ensureScribeBearer(readEnvFile, setEnvVar);
|
|
104
|
+
if (created) {
|
|
105
|
+
process.env.SCRIBE_AUTH_TOKEN = token;
|
|
106
|
+
console.log("[transcribe] generated SCRIBE_AUTH_TOKEN (32 bytes, base64url) — mirror this value into scribe's config");
|
|
107
|
+
}
|
|
84
108
|
transcriptionWorker = startTranscriptionWorker({
|
|
85
109
|
vaultList: () => listVaults(),
|
|
86
110
|
getStore: (name) => getVaultStore(name),
|
|
87
|
-
scribeUrl
|
|
111
|
+
scribeUrl,
|
|
88
112
|
scribeToken: resolveScribeAuthToken(),
|
|
89
113
|
resolveAssetsDir: (vault) => assetsDir(vault),
|
|
90
114
|
getAudioRetention: (vault) => readVaultConfig(vault)?.audio_retention ?? "keep",
|
|
@@ -97,9 +121,12 @@ if (process.env.SCRIBE_URL) {
|
|
|
97
121
|
transcriptionWorker,
|
|
98
122
|
(store) => getVaultNameForStore(store as never),
|
|
99
123
|
);
|
|
100
|
-
|
|
124
|
+
// Expose the worker to the REST retry endpoint so retries kick immediately
|
|
125
|
+
// instead of waiting for the sweep. Idempotent on second boot.
|
|
126
|
+
setTranscriptionWorker(transcriptionWorker);
|
|
127
|
+
console.log(`[transcribe] worker started → ${scribeUrl}`);
|
|
101
128
|
} else {
|
|
102
|
-
console.log("[transcribe] worker disabled (
|
|
129
|
+
console.log("[transcribe] worker disabled (no scribe in services.json and SCRIBE_URL unset)");
|
|
103
130
|
}
|
|
104
131
|
|
|
105
132
|
if (process.env.VAULT_AUTH_TOKEN?.trim()) {
|
|
@@ -151,6 +178,14 @@ if (listVaults().length === 0) {
|
|
|
151
178
|
}
|
|
152
179
|
}
|
|
153
180
|
|
|
181
|
+
// vault#266 — self-register manifest + installDir into services.json so
|
|
182
|
+
// hub's discovery / admin SPA can find vault without a `parachute install
|
|
183
|
+
// vault` round-trip. Idempotent (re-runs produce the same row when nothing
|
|
184
|
+
// changed); never throws (boot must not fail on bookkeeping). The merge-
|
|
185
|
+
// preserving `upsertService` ensures any hub-stamped fields on the row
|
|
186
|
+
// survive — see self-register.ts header for the v0.6 vs v0.7 design note.
|
|
187
|
+
selfRegister({ version: pkg.version });
|
|
188
|
+
|
|
154
189
|
// Migrate tag schemas from vault.yaml → DB for each vault.
|
|
155
190
|
// Only inserts schemas that don't already exist in the DB (safe across restarts).
|
|
156
191
|
for (const vaultName of listVaults()) {
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for vault transcript-note materialization (vault#353).
|
|
3
|
+
*
|
|
4
|
+
* Covers two surfaces:
|
|
5
|
+
* - `buildTranscriptNote` — pure, frontmatter shape per design Q3.
|
|
6
|
+
* - `upsertTranscriptNote` — DB-backed, create + retry-update flow.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
10
|
+
import { Database } from "bun:sqlite";
|
|
11
|
+
import { mkdirSync, rmSync } from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { tmpdir } from "os";
|
|
14
|
+
import { BunStore } from "./vault-store.ts";
|
|
15
|
+
import { buildTranscriptNote, transcriptPathFor, upsertTranscriptNote } from "./transcript-note.ts";
|
|
16
|
+
|
|
17
|
+
let db: Database;
|
|
18
|
+
let store: BunStore;
|
|
19
|
+
let tmpDir: string;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
tmpDir = join(tmpdir(), `transcript-note-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
23
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
24
|
+
db = new Database(join(tmpDir, "test.db"));
|
|
25
|
+
store = new BunStore(db);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
db.close();
|
|
30
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("transcriptPathFor", () => {
|
|
34
|
+
test("appends `.transcript` to the audio path", () => {
|
|
35
|
+
expect(transcriptPathFor("inbox/2026-05-21.m4a")).toBe("inbox/2026-05-21.m4a.transcript");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("handles nested paths", () => {
|
|
39
|
+
expect(transcriptPathFor("a/b/c/voice.webm")).toBe("a/b/c/voice.webm.transcript");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("buildTranscriptNote — success shape", () => {
|
|
44
|
+
test("frontmatter includes transcript_of, status, attachment id, duration, and provider", () => {
|
|
45
|
+
const result = buildTranscriptNote({
|
|
46
|
+
attachmentPath: "inbox/voice.webm",
|
|
47
|
+
attachmentId: "att-1",
|
|
48
|
+
attachmentNoteId: "note-1",
|
|
49
|
+
status: "complete",
|
|
50
|
+
text: "hello world",
|
|
51
|
+
provider: "groq",
|
|
52
|
+
durationMs: 1234,
|
|
53
|
+
createdAt: new Date("2026-05-21T09:13:42Z"),
|
|
54
|
+
});
|
|
55
|
+
expect(result.path).toBe("inbox/voice.webm.transcript");
|
|
56
|
+
expect(result.content).toBe("hello world");
|
|
57
|
+
expect(result.tags).toEqual(["transcript", "capture"]);
|
|
58
|
+
expect(result.metadata.transcript_of).toBe("inbox/voice.webm");
|
|
59
|
+
expect(result.metadata.transcript_attachment_id).toBe("att-1");
|
|
60
|
+
expect(result.metadata.transcript_status).toBe("complete");
|
|
61
|
+
expect(result.metadata.transcript_provider).toBe("groq");
|
|
62
|
+
expect(result.metadata.transcript_duration_ms).toBe(1234);
|
|
63
|
+
expect(result.metadata.title).toBe("Transcript of voice.webm");
|
|
64
|
+
expect(result.createdAt).toBe("2026-05-21T09:13:42.000Z");
|
|
65
|
+
expect(result.metadata.transcript_error).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("provider/duration omitted when not supplied", () => {
|
|
69
|
+
const result = buildTranscriptNote({
|
|
70
|
+
attachmentPath: "voice.m4a",
|
|
71
|
+
attachmentId: "att-x",
|
|
72
|
+
attachmentNoteId: "note-x",
|
|
73
|
+
status: "complete",
|
|
74
|
+
text: "no provider info",
|
|
75
|
+
});
|
|
76
|
+
expect(result.metadata.transcript_provider).toBeUndefined();
|
|
77
|
+
expect(result.metadata.transcript_duration_ms).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("buildTranscriptNote — failure shape", () => {
|
|
82
|
+
test("body is empty + transcript_error captured", () => {
|
|
83
|
+
const result = buildTranscriptNote({
|
|
84
|
+
attachmentPath: "inbox/voice.webm",
|
|
85
|
+
attachmentId: "att-1",
|
|
86
|
+
attachmentNoteId: "note-1",
|
|
87
|
+
status: "failed",
|
|
88
|
+
error: "no transcription provider configured",
|
|
89
|
+
});
|
|
90
|
+
expect(result.content).toBe("");
|
|
91
|
+
expect(result.metadata.transcript_status).toBe("failed");
|
|
92
|
+
expect(result.metadata.transcript_error).toBe("no transcription provider configured");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("falls back to 'unknown error' when no error string is supplied", () => {
|
|
96
|
+
const result = buildTranscriptNote({
|
|
97
|
+
attachmentPath: "voice.m4a",
|
|
98
|
+
attachmentId: "att-1",
|
|
99
|
+
attachmentNoteId: "note-1",
|
|
100
|
+
status: "failed",
|
|
101
|
+
});
|
|
102
|
+
expect(result.metadata.transcript_error).toBe("unknown error");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("upsertTranscriptNote", () => {
|
|
107
|
+
test("creates a new note + link on first call", async () => {
|
|
108
|
+
const audioOwner = await store.createNote("# Voice memo\n", { id: "owner" });
|
|
109
|
+
await store.addAttachment(audioOwner.id, "memos/a.webm", "audio/webm");
|
|
110
|
+
|
|
111
|
+
const note = await upsertTranscriptNote(store, {
|
|
112
|
+
attachmentPath: "memos/a.webm",
|
|
113
|
+
attachmentId: "att-1",
|
|
114
|
+
attachmentNoteId: audioOwner.id,
|
|
115
|
+
status: "complete",
|
|
116
|
+
text: "spoken words",
|
|
117
|
+
provider: "groq",
|
|
118
|
+
durationMs: 999,
|
|
119
|
+
});
|
|
120
|
+
expect(note.content).toBe("spoken words");
|
|
121
|
+
expect(note.path).toBe("memos/a.webm.transcript");
|
|
122
|
+
|
|
123
|
+
const fetched = await store.getNoteByPath("memos/a.webm.transcript");
|
|
124
|
+
expect(fetched?.id).toBe(note.id);
|
|
125
|
+
expect((fetched?.metadata as any)?.transcript_status).toBe("complete");
|
|
126
|
+
expect(fetched?.tags).toContain("transcript");
|
|
127
|
+
expect(fetched?.tags).toContain("capture");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("overwrites existing transcript note in place on retry (id preserved)", async () => {
|
|
131
|
+
const owner = await store.createNote("# Voice memo\n", { id: "owner-2" });
|
|
132
|
+
await store.addAttachment(owner.id, "memos/b.webm", "audio/webm");
|
|
133
|
+
|
|
134
|
+
const first = await upsertTranscriptNote(store, {
|
|
135
|
+
attachmentPath: "memos/b.webm",
|
|
136
|
+
attachmentId: "att-2",
|
|
137
|
+
attachmentNoteId: owner.id,
|
|
138
|
+
status: "failed",
|
|
139
|
+
error: "no transcription provider configured",
|
|
140
|
+
});
|
|
141
|
+
expect(first.content).toBe("");
|
|
142
|
+
|
|
143
|
+
const retried = await upsertTranscriptNote(store, {
|
|
144
|
+
attachmentPath: "memos/b.webm",
|
|
145
|
+
attachmentId: "att-2",
|
|
146
|
+
attachmentNoteId: owner.id,
|
|
147
|
+
status: "complete",
|
|
148
|
+
text: "transcript that finally landed",
|
|
149
|
+
durationMs: 500,
|
|
150
|
+
});
|
|
151
|
+
expect(retried.id).toBe(first.id);
|
|
152
|
+
expect(retried.content).toBe("transcript that finally landed");
|
|
153
|
+
expect((retried.metadata as any)?.transcript_status).toBe("complete");
|
|
154
|
+
expect((retried.metadata as any)?.transcript_error).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("attempting to upsert when the attachmentNoteId is missing still creates the note (link skipped)", async () => {
|
|
158
|
+
// No owner note created — the link create will throw or silently skip;
|
|
159
|
+
// either way the transcript note must still land so the failure has
|
|
160
|
+
// a visible record. (Defensive: a deleted-attachment race.)
|
|
161
|
+
const note = await upsertTranscriptNote(store, {
|
|
162
|
+
attachmentPath: "missing/a.webm",
|
|
163
|
+
attachmentId: "att-x",
|
|
164
|
+
attachmentNoteId: "nonexistent-note",
|
|
165
|
+
status: "failed",
|
|
166
|
+
error: "audio file not found",
|
|
167
|
+
});
|
|
168
|
+
expect(note.path).toBe("missing/a.webm.transcript");
|
|
169
|
+
expect((note.metadata as any)?.transcript_status).toBe("failed");
|
|
170
|
+
});
|
|
171
|
+
});
|