@openparachute/vault 0.4.7-rc.2 → 0.4.8-rc.6

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.
Files changed (42) hide show
  1. package/.parachute/module.json +0 -1
  2. package/README.md +44 -10
  3. package/core/src/connection-pragmas.test.ts +232 -0
  4. package/core/src/core.test.ts +257 -0
  5. package/core/src/cursor.test.ts +160 -0
  6. package/core/src/cursor.ts +272 -0
  7. package/core/src/mcp.ts +51 -7
  8. package/core/src/notes.ts +164 -2
  9. package/core/src/schema.ts +98 -2
  10. package/core/src/store.ts +11 -1
  11. package/core/src/types.ts +32 -0
  12. package/package.json +1 -1
  13. package/src/auth-status.ts +4 -0
  14. package/src/auto-transcribe.test.ts +116 -0
  15. package/src/auto-transcribe.ts +48 -0
  16. package/src/cli.ts +57 -48
  17. package/src/config.test.ts +26 -0
  18. package/src/config.ts +53 -1
  19. package/src/db.ts +15 -2
  20. package/src/mcp-install-interactive.test.ts +23 -2
  21. package/src/mcp-install-interactive.ts +21 -2
  22. package/src/mcp-install.test.ts +40 -0
  23. package/src/mcp-tools.ts +17 -1
  24. package/src/module-config.ts +70 -14
  25. package/src/module-manifest.test.ts +114 -0
  26. package/src/module-manifest.ts +104 -0
  27. package/src/routes.ts +268 -51
  28. package/src/routing.test.ts +4 -2
  29. package/src/routing.ts +4 -4
  30. package/src/scribe-discovery.test.ts +77 -0
  31. package/src/scribe-discovery.ts +91 -0
  32. package/src/scribe-env.test.ts +66 -1
  33. package/src/scribe-env.ts +42 -1
  34. package/src/self-register.test.ts +379 -0
  35. package/src/self-register.ts +234 -0
  36. package/src/server.ts +46 -11
  37. package/src/transcript-note.test.ts +171 -0
  38. package/src/transcript-note.ts +189 -0
  39. package/src/transcription-registry.ts +22 -0
  40. package/src/transcription-worker.test.ts +250 -0
  41. package/src/transcription-worker.ts +186 -27
  42. 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
- if (process.env.SCRIBE_URL) {
51
- const scribeHost = safeHost(process.env.SCRIBE_URL);
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 SCRIBE_URL is configured. The worker
78
- * polls every vault for attachments with `metadata.transcribe_status = "pending"`
79
- * and sends the audio to scribe. Absent SCRIBE_URL, the worker stays off
80
- * — `{transcribe: true}` uploads still enqueue, they just wait.
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 (process.env.SCRIBE_URL) {
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: process.env.SCRIBE_URL,
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
- console.log(`[transcribe] worker started ${process.env.SCRIBE_URL}`);
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 (set SCRIBE_URL to enable)");
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
+ });