@openparachute/vault 0.7.9-rc.6 → 0.7.9-rc.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/src/history-capture.test.ts +9 -1
- package/core/src/history-visibility.test.ts +11 -0
- package/core/src/history-visibility.ts +9 -0
- package/core/src/history.test.ts +57 -0
- package/core/src/history.ts +12 -1
- package/core/src/store.ts +2 -0
- package/package.json +1 -1
- package/src/attachment-tickets.test.ts +49 -27
- package/src/auto-transcribe-integration.test.ts +82 -0
- package/src/auto-transcribe.test.ts +126 -374
- package/src/auto-transcribe.ts +43 -215
- package/src/history-import-routes.test.ts +23 -1
- package/src/history-import.test.ts +34 -2
- package/src/history-import.ts +4 -1
- package/src/mcp-tools.ts +8 -2
- package/src/routes.ts +5 -3
- package/src/server.ts +7 -7
- package/src/transcription-registry.ts +9 -7
- package/src/vault-compact-routes.test.ts +1 -1
- package/src/vault-history-routes.test.ts +50 -1
- package/src/vault.test.ts +40 -17
package/src/auto-transcribe.ts
CHANGED
|
@@ -1,247 +1,75 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Auto-transcribe trigger decision (vault#353, design 2026-05-21 Part 2).
|
|
3
|
-
*
|
|
4
|
-
* One pure function: given an attachment's mime-type + the operator's
|
|
5
|
-
* settings + whether scribe is reachable, decide whether to enqueue the
|
|
6
|
-
* attachment for the transcription worker. Lives in its own module so the
|
|
7
|
-
* attachment-write code path (`routes.ts`) and the retry endpoint share the
|
|
8
|
-
* same gate without duplicating logic.
|
|
9
|
-
*/
|
|
10
|
-
|
|
1
|
+
/** Automatic uploads follow the selected, wired transcription worker (#751). */
|
|
11
2
|
import { readGlobalConfig } from "./config.ts";
|
|
12
|
-
import { getCachedScribeUrl } from "./scribe-discovery.ts";
|
|
13
3
|
import { buildTranscriptionSnapshot } from "./transcription-routes.ts";
|
|
4
|
+
import { getTranscriptionWorkerProvider } from "./transcription-registry.ts";
|
|
14
5
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
*
|
|
18
|
-
* Delegates to the same snapshot `transcription status` and the admin SPA
|
|
19
|
-
* answer from, so these surfaces share one implementation and cannot drift.
|
|
20
|
-
*
|
|
21
|
-
* Keyed on `binary.path`, deliberately, NOT on `snap.ready`. Readiness also
|
|
22
|
-
* demands ffmpeg, so it varies box to box in a way this message shouldn't: a
|
|
23
|
-
* machine with the provider installed but ffmpeg missing is still a machine
|
|
24
|
-
* where "no transcription provider configured — set TRANSCRIPTION_PROVIDER to
|
|
25
|
-
* a local provider" is false. `installed` answers the question the sentence
|
|
26
|
-
* actually asks, and answers it the same way everywhere.
|
|
27
|
-
* (Caught by running the real probe rather than the injected one — the unit
|
|
28
|
-
* tests inject `localProviderImpl` and would never have shown the difference.)
|
|
29
|
-
*
|
|
30
|
-
* Errors swallow to null: a broken probe must degrade to the old, flat message,
|
|
31
|
-
* never take down an attachment upload.
|
|
32
|
-
*/
|
|
33
|
-
function installedLocalProvider(): string | null {
|
|
6
|
+
type ProviderState = { ready: boolean; localProvider: string | null };
|
|
7
|
+
function providerState(): ProviderState {
|
|
34
8
|
try {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
9
|
+
const snapshot = buildTranscriptionSnapshot();
|
|
10
|
+
return {
|
|
11
|
+
ready: snapshot.active && snapshot.ready &&
|
|
12
|
+
getTranscriptionWorkerProvider() === snapshot.provider,
|
|
13
|
+
// The snapshot's binary field describes whisper.cpp tools, not every
|
|
14
|
+
// provider. Do not use it to claim a Python/transcribe-cpp install exists.
|
|
15
|
+
localProvider: snapshot.provider === "whisper-cpp" && snapshot.binary.path
|
|
16
|
+
? snapshot.provider : null,
|
|
17
|
+
};
|
|
38
18
|
} catch {
|
|
39
|
-
|
|
19
|
+
// A failed readiness probe must not break attachment upload.
|
|
20
|
+
return { ready: false, localProvider: null };
|
|
40
21
|
}
|
|
41
22
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
* 1. mime-type starts with `audio/` (case-insensitive).
|
|
51
|
-
* 2. The resolved auto-transcribe toggle is not `false`. Resolution is
|
|
52
|
-
* **per-vault → global → true**:
|
|
53
|
-
* - `perVaultEnabled` (the owning vault's own `auto_transcribe.enabled`)
|
|
54
|
-
* wins when set — this is what makes scribe's "link to vault X" affect
|
|
55
|
-
* only X, not the whole server.
|
|
56
|
-
* - else the server-wide `globalConfig.auto_transcribe?.enabled`.
|
|
57
|
-
* - else `true` (default ON — once scribe is reachable, audio
|
|
58
|
-
* transcribes without a separate config step). Operators who want it
|
|
59
|
-
* OFF set `auto_transcribe.enabled: false` explicitly (per-vault or
|
|
60
|
-
* globally).
|
|
61
|
-
* `enabledOverride`, when present, hard-overrides the whole chain (used
|
|
62
|
-
* by the explicit caller-opt-in path).
|
|
63
|
-
* 3. Scribe is discoverable (services.json entry OR SCRIBE_URL env).
|
|
64
|
-
*
|
|
65
|
-
* The three conditions are independent guards: a single `false` is sufficient
|
|
66
|
-
* to skip enqueuing. The audio stays as a regular attachment in that case.
|
|
67
|
-
*/
|
|
68
|
-
export function shouldAutoTranscribe(
|
|
69
|
-
mimeType: string,
|
|
70
|
-
opts: {
|
|
71
|
-
/** Injection seam for tests — defaults to live globals. */
|
|
72
|
-
readGlobalConfigImpl?: typeof readGlobalConfig;
|
|
73
|
-
getCachedScribeUrlImpl?: () => string | undefined;
|
|
74
|
-
/**
|
|
75
|
-
* The owning vault's per-vault `auto_transcribe.enabled` (vault.yaml).
|
|
76
|
-
* Takes precedence over the global toggle when set, so enabling/disabling
|
|
77
|
-
* one vault doesn't move the rest. `undefined` (the vault left it unset)
|
|
78
|
-
* falls through to the global toggle.
|
|
79
|
-
*/
|
|
80
|
-
perVaultEnabled?: boolean;
|
|
81
|
-
/**
|
|
82
|
-
* Hard override of the entire per-vault→global→true chain. Used by the
|
|
83
|
-
* explicit caller-opt-in path; not part of the normal precedence ladder.
|
|
84
|
-
*/
|
|
85
|
-
enabledOverride?: boolean;
|
|
86
|
-
} = {},
|
|
87
|
-
): boolean {
|
|
88
|
-
if (typeof mimeType !== "string" || !mimeType.toLowerCase().startsWith("audio/")) {
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
return classifyAutoTranscribe(mimeType, opts).kind === "transcribe";
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Why an audio attachment is (or isn't) being transcribed. The three outcomes
|
|
96
|
-
* are NOT interchangeable, which is the whole point of this type:
|
|
97
|
-
*
|
|
98
|
-
* - `transcribe` — enqueue it.
|
|
99
|
-
* - `disabled` — the operator turned auto-transcribe off. Silence is
|
|
100
|
-
* correct: they asked for nothing to happen.
|
|
101
|
-
* - `unavailable` — auto-transcribe is ON, but no provider is reachable.
|
|
102
|
-
* This is a MISCONFIGURATION, and silence is wrong.
|
|
103
|
-
*
|
|
104
|
-
* vault#643: `shouldAutoTranscribe` collapsed the last two into `false`, so a
|
|
105
|
-
* box with transcription enabled and no reachable provider accepted audio,
|
|
106
|
-
* transcribed nothing, wrote no marker, logged nothing, and left an attachment
|
|
107
|
-
* indistinguishable from a plain upload. Observed on a fresh install: the
|
|
108
|
-
* provider resolves to `scribe-http` by default, nothing sets `SCRIBE_URL`, and
|
|
109
|
-
* voice memos silently never transcribe.
|
|
110
|
-
*
|
|
111
|
-
* The hosted door already gets this right — `workers/vault/src/vault-do.ts`
|
|
112
|
-
* marks a terminal state ("voice not enabled for this plan", "monthly voice
|
|
113
|
-
* limit reached") rather than skipping quietly, precisely so the operator never
|
|
114
|
-
* faces an eternal spinner. This brings self-host to the same posture.
|
|
115
|
-
*/
|
|
23
|
+
type AutoTranscribeOptions = {
|
|
24
|
+
readGlobalConfigImpl?: typeof readGlobalConfig;
|
|
25
|
+
/** Test seam; production uses the registered worker and shared status probe. */
|
|
26
|
+
providerStateImpl?: () => ProviderState;
|
|
27
|
+
/** Per-vault → global → true; explicit caller overrides still win. */
|
|
28
|
+
perVaultEnabled?: boolean;
|
|
29
|
+
enabledOverride?: boolean;
|
|
30
|
+
};
|
|
116
31
|
export type AutoTranscribeDecision =
|
|
117
32
|
| { kind: "transcribe" }
|
|
118
33
|
| { kind: "not-audio" }
|
|
119
34
|
| { kind: "disabled" }
|
|
120
|
-
/**
|
|
121
|
-
* `localProvider` names a local provider INSTALLED on this box (its binary
|
|
122
|
-
* resolves), or null when there is nothing local here.
|
|
123
|
-
*
|
|
124
|
-
* Installation, deliberately, and not the configured provider NAME. The name
|
|
125
|
-
* is a trap: `resolveTranscriptionProviderName` returns `whisper-cpp` on a
|
|
126
|
-
* box where nothing at all is set up, because that is the default when no
|
|
127
|
-
* scribe URL resolves. Keying the message off the name would tell a
|
|
128
|
-
* fresh-install operator their whisper-cpp install is present when they have
|
|
129
|
-
* never installed one — trading one false sentence for another.
|
|
130
|
-
*
|
|
131
|
-
* It does NOT change the decision: `unavailable` is `unavailable` either way,
|
|
132
|
-
* because this path resolves a scribe URL and nothing else. It exists purely
|
|
133
|
-
* so the error can be true. See `noProviderErrorFor`.
|
|
134
|
-
*/
|
|
135
35
|
| { kind: "unavailable"; localProvider: string | null };
|
|
136
36
|
|
|
137
|
-
/** The full decision behind `shouldAutoTranscribe`. Same inputs, more answer. */
|
|
138
37
|
export function classifyAutoTranscribe(
|
|
139
38
|
mimeType: string,
|
|
140
|
-
opts: {
|
|
141
|
-
readGlobalConfigImpl?: typeof readGlobalConfig;
|
|
142
|
-
getCachedScribeUrlImpl?: () => string | undefined;
|
|
143
|
-
perVaultEnabled?: boolean;
|
|
144
|
-
enabledOverride?: boolean;
|
|
145
|
-
/**
|
|
146
|
-
* Injectable, same shape as the two impls above. Returns the name of a
|
|
147
|
-
* local provider installed here, or null. Defaults to the shared
|
|
148
|
-
* transcription snapshot.
|
|
149
|
-
*/
|
|
150
|
-
localProviderImpl?: () => string | null;
|
|
151
|
-
} = {},
|
|
39
|
+
opts: AutoTranscribeOptions = {},
|
|
152
40
|
): AutoTranscribeDecision {
|
|
153
41
|
if (typeof mimeType !== "string" || !mimeType.toLowerCase().startsWith("audio/")) {
|
|
154
42
|
return { kind: "not-audio" };
|
|
155
43
|
}
|
|
156
|
-
const enabled = opts.enabledOverride
|
|
157
|
-
??
|
|
158
|
-
?? (opts.readGlobalConfigImpl ?? readGlobalConfig)().auto_transcribe?.enabled
|
|
159
|
-
?? true;
|
|
44
|
+
const enabled = opts.enabledOverride ?? opts.perVaultEnabled ??
|
|
45
|
+
(opts.readGlobalConfigImpl ?? readGlobalConfig)().auto_transcribe?.enabled ?? true;
|
|
160
46
|
if (!enabled) return { kind: "disabled" };
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
kind: "unavailable",
|
|
169
|
-
localProvider: (opts.localProviderImpl ?? installedLocalProvider)(),
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
return { kind: "transcribe" };
|
|
47
|
+
const state = (opts.providerStateImpl ?? providerState)();
|
|
48
|
+
return state.ready
|
|
49
|
+
? { kind: "transcribe" }
|
|
50
|
+
: { kind: "unavailable", localProvider: state.localProvider };
|
|
51
|
+
}
|
|
52
|
+
export function shouldAutoTranscribe(mimeType: string, opts: AutoTranscribeOptions = {}): boolean {
|
|
53
|
+
return classifyAutoTranscribe(mimeType, opts).kind === "transcribe";
|
|
173
54
|
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* The `transcribe_error` written when auto-transcribe is on but no provider is
|
|
177
|
-
* reachable AND nothing local is configured. Deliberately actionable — the
|
|
178
|
-
* operator needs to know which of the two things to do, not just that something
|
|
179
|
-
* went wrong.
|
|
180
|
-
*
|
|
181
|
-
* Correct only when the resolved provider is `scribe-http`. On a box that HAS a
|
|
182
|
-
* local provider this sentence is false in the most expensive way — see
|
|
183
|
-
* `noProviderErrorFor`.
|
|
184
|
-
*/
|
|
185
55
|
export const NO_PROVIDER_ERROR =
|
|
186
|
-
"no transcription provider configured —
|
|
187
|
-
"
|
|
188
|
-
"
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* The `transcribe_error` for an `unavailable` decision, told truthfully.
|
|
192
|
-
*
|
|
193
|
-
* Found live: a box with `TRANSCRIPTION_PROVIDER=whisper-cpp`, `parakeet-cli` on
|
|
194
|
-
* PATH, the model downloaded, and `[transcribe] worker started → whisper-cpp` in
|
|
195
|
-
* the same boot was writing `NO_PROVIDER_ERROR` onto its attachments — telling
|
|
196
|
-
* the operator to set the variable they had set and install the provider that
|
|
197
|
-
* was already running. Same failure mode as the `transcription status` bug
|
|
198
|
-
* (#643): a diagnostic that lies during the exact task it exists for, and this
|
|
199
|
-
* one sends someone to re-download a 400 MB model to fix nothing.
|
|
200
|
-
*
|
|
201
|
-
* The asymmetry is real and worth naming in the message rather than hiding:
|
|
202
|
-
* `server.ts` starts a local worker with no scribe URL anywhere in the branch,
|
|
203
|
-
* but this path resolves ONLY a scribe URL. So a local provider genuinely does
|
|
204
|
-
* serve explicit `transcribe: true` requests while genuinely being invisible
|
|
205
|
-
* here. An operator who is not told that will reasonably conclude their install
|
|
206
|
-
* is broken.
|
|
207
|
-
*
|
|
208
|
-
* Deliberately NOT a fix for the asymmetry itself — teaching this path to see
|
|
209
|
-
* local providers changes what gets transcribed, which is a behaviour question
|
|
210
|
-
* and not this diff's to answer.
|
|
211
|
-
*/
|
|
56
|
+
"no transcription provider configured or ready — check `parachute-vault transcription status` " +
|
|
57
|
+
"and TRANSCRIPTION_PROVIDER. For local transcription use `parachute-vault transcription install`. " +
|
|
58
|
+
"For an explicitly selected legacy remote provider, check its SCRIBE_URL and worker status. " +
|
|
59
|
+
"The standalone Scribe service is retired.";
|
|
212
60
|
export function noProviderErrorFor(localProvider: string | null): string {
|
|
213
61
|
if (!localProvider) return NO_PROVIDER_ERROR;
|
|
214
|
-
return
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
`are routed to it — this path does not consult it. Reinstalling ${localProvider} will ` +
|
|
219
|
-
`not change this. To auto-transcribe uploads, point SCRIBE_URL at a transcription ` +
|
|
220
|
-
`service; otherwise request transcription explicitly on the attachment. ` +
|
|
221
|
-
`\`parachute-vault transcription status\` reports the local install's own state.`
|
|
222
|
-
);
|
|
62
|
+
return `auto-transcribe is unavailable: a local ${localProvider} binary is installed, ` +
|
|
63
|
+
"but the selected worker is not active and ready. Check `parachute-vault transcription status` " +
|
|
64
|
+
"for missing model/runtime dependencies or a required server restart. " +
|
|
65
|
+
"A binary alone does not establish readiness; reinstalling it is not necessarily the fix.";
|
|
223
66
|
}
|
|
224
|
-
|
|
225
|
-
/** Throttle for {@link warnNoTranscriptionProvider}. */
|
|
226
67
|
const NO_PROVIDER_WARN_INTERVAL_MS = 60_000;
|
|
227
68
|
let lastNoProviderWarnAt = 0;
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Warn (at most once a minute) that audio is arriving with nowhere to send it.
|
|
231
|
-
*
|
|
232
|
-
* Throttled because it fires per-upload: a bulk import of a hundred voice
|
|
233
|
-
* memos should produce one actionable line, not a hundred. Silence was the old
|
|
234
|
-
* behaviour and it is what made this invisible — a box can accept audio for
|
|
235
|
-
* months and never say that transcription isn't wired up.
|
|
236
|
-
*/
|
|
237
69
|
export function warnNoTranscriptionProvider(now: () => number = Date.now): void {
|
|
238
70
|
const t = now();
|
|
239
71
|
if (t - lastNoProviderWarnAt < NO_PROVIDER_WARN_INTERVAL_MS) return;
|
|
240
72
|
lastNoProviderWarnAt = t;
|
|
241
|
-
console.warn(`[transcribe] audio attachment accepted but ${NO_PROVIDER_ERROR}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/** Test seam: forget the throttle window. */
|
|
245
|
-
export function _resetNoProviderWarnForTest(): void {
|
|
246
|
-
lastNoProviderWarnAt = 0;
|
|
73
|
+
console.warn(`[transcribe] audio attachment accepted but ${NO_PROVIDER_ERROR}`);
|
|
247
74
|
}
|
|
75
|
+
export function _resetNoProviderWarnForTest(): void { lastNoProviderWarnAt = 0; }
|
|
@@ -45,6 +45,24 @@ async function call(path: string, method = "GET", body?: unknown, verb = "admin"
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
import { applyImportedNote, beginImportRun, importTargetDigest } from "../core/src/history-import.ts";
|
|
48
|
+
test("authenticated history REST redacts scoped provenance for read/write/admin tokens", async () => {
|
|
49
|
+
const note = await store.createNote("before", { tags: ["journal"] });
|
|
50
|
+
await store.updateNote(note.id, { content: "after", actor: "HISTORICAL_EDITOR", via: "HISTORICAL_INTERFACE" });
|
|
51
|
+
for (const verb of ["read", "write", "admin"]) {
|
|
52
|
+
for (const scoped of [false, true]) {
|
|
53
|
+
for (const suffix of ["/versions", "/versions/0"]) {
|
|
54
|
+
const got = await call(`/notes/${note.id}${suffix}`, "GET", undefined, verb, scoped);
|
|
55
|
+
expect(got.status).toBe(200);
|
|
56
|
+
const row = got.body.versions?.[0] ?? got.body;
|
|
57
|
+
expect(Object.hasOwn(row, "actor")).toBe(!scoped);
|
|
58
|
+
expect(Object.hasOwn(row, "via")).toBe(!scoped);
|
|
59
|
+
if (!scoped) expect(row.actor).toBe("HISTORICAL_EDITOR");
|
|
60
|
+
expect(row.version_ix).toBe(0);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
expect((await store.getNoteVersion(note.id, 0))!.actor).toBe("HISTORICAL_EDITOR");
|
|
65
|
+
});
|
|
48
66
|
test("imported REST references restore by ID or encoded path and preserve native keys", async () => {
|
|
49
67
|
const note = await store.createNote("native", { path: "folder/imported", tags: ["journal"] });
|
|
50
68
|
await store.updateNote(note.id, { content: "current" });
|
|
@@ -66,7 +84,11 @@ test("imported REST references restore by ID or encoded path and preserve native
|
|
|
66
84
|
expect(list.body.versions.map((v: any) => v.version_ix ?? v.origin)).toEqual([0, "git-import"]);
|
|
67
85
|
for (const body of [{ origin: "git-import" }, { import_ix: 0 }, { origin: "git-import", import_ix: 0, version_ix: 0 }, { origin: "git-import", import_ix: -1 }]) expect((await call(`/notes/${note.id}/restore`, "POST", body)).status).toBe(400);
|
|
68
86
|
expect((await call(`/notes/${note.id}/versions/-1`)).status).toBe(400);
|
|
69
|
-
const
|
|
87
|
+
const beforeRestore = await store.getNote(note.id);
|
|
88
|
+
const missingStamp = await call(`/notes/${note.id}/restore`, "POST", { origin: "git-import", import_ix: 0 });
|
|
89
|
+
expect(missingStamp.status).toBe(428);
|
|
90
|
+
expect(await store.getNote(note.id)).toEqual(beforeRestore);
|
|
91
|
+
const restored = await call(`/notes/${note.id}/restore`, "POST", { origin: "git-import", import_ix: 0, if_updated_at: beforeRestore!.updatedAt });
|
|
70
92
|
expect(restored.status).toBe(200);
|
|
71
93
|
expect(restored.body.restored_from).toEqual({ origin: "git-import", import_ix: 0 });
|
|
72
94
|
expect((await store.getNote(note.id))!.content).toBe("archive");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { test, expect } from "bun:test";
|
|
1
|
+
import { test, expect, spyOn } from "bun:test";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { Database } from "bun:sqlite";
|
|
3
|
+
import { Database, SQLiteError } from "bun:sqlite";
|
|
4
4
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
@@ -119,6 +119,38 @@ test("ambiguous sidecars quarantine both identities; malformed YAML diagnostics
|
|
|
119
119
|
} finally { stage.close(); rmSync(dir, { recursive: true, force: true }); }
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
+
for (const format of ["inline", "sidecar"] as const) for (const cache of ["object_cache", "parsed_cache"] as const) {
|
|
123
|
+
test(`staging SQLite failure in ${format} ${cache} aborts without blaming source notes`, () => {
|
|
124
|
+
const dir = mkdtempSync(join(tmpdir(), "import-stage-failure-")), stage = new Database(":memory:");
|
|
125
|
+
const exec = stage.exec.bind(stage);
|
|
126
|
+
const setup = spyOn(stage, "exec").mockImplementation((sql) => {
|
|
127
|
+
const result = exec(sql);
|
|
128
|
+
if (sql.startsWith("CREATE TABLE selection_failures")) {
|
|
129
|
+
exec(`CREATE TRIGGER fail_cache BEFORE INSERT ON ${cache} BEGIN SELECT RAISE(ABORT, 'staging write failed'); END`);
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
});
|
|
133
|
+
try {
|
|
134
|
+
const repo = repoAt(join(dir, "repo"));
|
|
135
|
+
if (format === "inline") commit(repo, "valid body");
|
|
136
|
+
else {
|
|
137
|
+
mkdirSync(join(repo, ".parachute/notes-meta"), { recursive: true });
|
|
138
|
+
writeFileSync(join(repo, `.parachute/notes-meta/${noteId}.yaml`), `id: ${noteId}\npath: entry\nextension: json\n`);
|
|
139
|
+
writeFileSync(join(repo, "entry.json"), '{"valid":true}');
|
|
140
|
+
git(repo, "add", "."); git(repo, "commit", "-m", "valid sidecar");
|
|
141
|
+
}
|
|
142
|
+
let failure: unknown;
|
|
143
|
+
try { stageArchive(repo, git(repo, "rev-parse", "HEAD"), stage); }
|
|
144
|
+
catch (error) { failure = error; }
|
|
145
|
+
expect(failure).toBeInstanceOf(SQLiteError);
|
|
146
|
+
expect((failure as Error).message).toContain("staging write failed");
|
|
147
|
+
expect(stage.query("SELECT * FROM issues").all()).toEqual([]);
|
|
148
|
+
expect(stage.query("SELECT * FROM quarantine").all()).toEqual([]);
|
|
149
|
+
expect(stage.query("SELECT * FROM observations").all()).toEqual([]);
|
|
150
|
+
} finally { setup.mockRestore(); stage.close(); rmSync(dir, { recursive: true, force: true }); }
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
122
154
|
test("running daemon refuses prepare before creating a pause marker or backup", async () => {
|
|
123
155
|
const dir = mkdtempSync(join(tmpdir(), "import-busy-")), saved = process.env.PARACHUTE_HOME;
|
|
124
156
|
process.env.PARACHUTE_HOME = dir;
|
package/src/history-import.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Offline git-history import. Git objects are data; no working tree is checked out. */
|
|
2
|
-
import { Database } from "bun:sqlite";
|
|
2
|
+
import { Database, SQLiteError } from "bun:sqlite";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, mkdtempSync, openSync, readSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -182,6 +182,9 @@ export function stageArchive(repo: string, tip: string, stage: Database, inputSe
|
|
|
182
182
|
collect(id, state, () => JSON.parse((parsedBody.get(cacheKey) as { row_json: string }).row_json), { git_path: entry.path, blob: entry.hash, sidecar });
|
|
183
183
|
};
|
|
184
184
|
const failure = (entry: typeof entries[number], error: unknown, id?: unknown) => {
|
|
185
|
+
// Staging/cache failures are operational, not evidence of malformed source.
|
|
186
|
+
// Abort so no diagnostic manifest can bless an incomplete archive walk.
|
|
187
|
+
if (error instanceof SQLiteError) throw error;
|
|
185
188
|
const message = error instanceof Error ? error.message : "invalid_observation";
|
|
186
189
|
const reason = /^[a-z_]+$/.test(message) ? message : "invalid_metadata_or_utf8";
|
|
187
190
|
if (error instanceof GitArchiveError || message === "Archive byte limit exceeded" || message === "Archive note limit exceeded") throw error;
|
package/src/mcp-tools.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { generateMcpTools, resolveNote } from "../core/src/mcp.ts";
|
|
9
|
+
import { projectHistoryProvenance } from "../core/src/history-visibility.js";
|
|
9
10
|
import type { McpToolDef, GenerateMcpToolsOpts } from "../core/src/mcp.ts";
|
|
10
11
|
import { getNote, getNoteTags, getVaultMap } from "../core/src/notes.ts";
|
|
11
12
|
import { narrowLinkWarningsForVisibility } from "../core/src/wikilinks.ts";
|
|
@@ -579,8 +580,13 @@ function applyTagScopeWrappers(
|
|
|
579
580
|
)) {
|
|
580
581
|
const id = (params as any).versions.note_id;
|
|
581
582
|
const note = resolveNote(store.db, id);
|
|
582
|
-
|
|
583
|
-
|
|
583
|
+
if (!note || !noteWithinTagScope(note, allowed, rawTags)) {
|
|
584
|
+
return { error: "Note not found", error_type: "not_found", id };
|
|
585
|
+
}
|
|
586
|
+
if ("versions" in result && Array.isArray(result.versions)) {
|
|
587
|
+
return { ...result, versions: result.versions.map(row => projectHistoryProvenance(row, true)) };
|
|
588
|
+
}
|
|
589
|
+
return projectHistoryProvenance(result, true);
|
|
584
590
|
}
|
|
585
591
|
// Possible response shapes (vault#550 added the `warnings` variants):
|
|
586
592
|
// - Array (legacy list, no cursor, no warnings)
|
package/src/routes.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { getImportedVersion, importStorageIndex, parseHistorySelector, projectHi
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { HistoryNotFoundError, latestTombstone } from "../core/src/history.js";
|
|
16
|
+
import { projectHistoryProvenance } from "../core/src/history-visibility.js";
|
|
16
17
|
import { ULID_REGEX } from "../core/src/ulid.js";
|
|
17
18
|
import type { Database } from "bun:sqlite";
|
|
18
19
|
import type { Store, Note, QueryOpts, AggregateSpec } from "../core/src/types.ts";
|
|
@@ -268,6 +269,7 @@ function conflictResponse(e: any): Response | null {
|
|
|
268
269
|
400,
|
|
269
270
|
);
|
|
270
271
|
}
|
|
272
|
+
if (e && e.code === "PRECONDITION_REQUIRED") return json({ error_type: "precondition_required", note_id: e.note_id, message: e.message }, 428);
|
|
271
273
|
if (e && e.code === "HISTORY_NOT_FOUND") return json({ error: e.message, error_type: "not_found" }, 404);
|
|
272
274
|
if (e && e.code === "HISTORY_OVERFLOW") return json({
|
|
273
275
|
error_type: "history_overflow", note_id: e.note_id, byte_size: e.byte_size, limit: e.limit, message: e.message,
|
|
@@ -2942,7 +2944,7 @@ async function handleNotesInner(
|
|
|
2942
2944
|
const offset = Math.max(0, Number.parseInt(url.searchParams.get("offset") ?? "0", 10) || 0);
|
|
2943
2945
|
const versions = await store.listNoteVersions(id, { limit, offset });
|
|
2944
2946
|
const total = await store.countNoteVersions(id);
|
|
2945
|
-
return json({ versions: versions.map(projectHistoryRow), total });
|
|
2947
|
+
return json({ versions: versions.map(row => projectHistoryProvenance(projectHistoryRow(row), tagScope.allowed !== null)), total });
|
|
2946
2948
|
}
|
|
2947
2949
|
if (method === "DELETE") {
|
|
2948
2950
|
const result = await store.eraseNoteHistory(id);
|
|
@@ -2953,7 +2955,7 @@ async function handleNotesInner(
|
|
|
2953
2955
|
if (!/^\d+$/.test(importMatch[1]!) || !Number.isSafeInteger(index) || index < 0) return json({ error: "Invalid import_ix", error_type: "invalid_request" }, 400);
|
|
2954
2956
|
try {
|
|
2955
2957
|
const version = getImportedVersion(db, id, index);
|
|
2956
|
-
return version ? json(projectHistoryRow(version)) : json({ error: "Not found", error_type: "not_found", origin: "git-import", import_ix: index }, 404);
|
|
2958
|
+
return version ? json(projectHistoryProvenance(projectHistoryRow(version), tagScope.allowed !== null)) : json({ error: "Not found", error_type: "not_found", origin: "git-import", import_ix: index }, 404);
|
|
2957
2959
|
} catch (e) {
|
|
2958
2960
|
const response = conflictResponse(e);
|
|
2959
2961
|
if (response) return response;
|
|
@@ -2964,7 +2966,7 @@ async function handleNotesInner(
|
|
|
2964
2966
|
if (!/^\d+$/.test(verMatch[1]!) || !Number.isInteger(ix) || ix < 0) return json({ error: "Invalid version_ix", error_type: "invalid_request" }, 400);
|
|
2965
2967
|
try {
|
|
2966
2968
|
const version = await store.getNoteVersion(id, ix);
|
|
2967
|
-
return version ? json(version) : json({ error: "Not found", error_type: "not_found" }, 404);
|
|
2969
|
+
return version ? json(projectHistoryProvenance(version, tagScope.allowed !== null)) : json({ error: "Not found", error_type: "not_found" }, 404);
|
|
2968
2970
|
} catch (e) {
|
|
2969
2971
|
const response = conflictResponse(e);
|
|
2970
2972
|
if (response) return response;
|
package/src/server.ts
CHANGED
|
@@ -145,7 +145,7 @@ function wireTranscriptionWorker(worker: TranscriptionWorker): void {
|
|
|
145
145
|
worker,
|
|
146
146
|
(store) => getVaultNameForStore(store as never),
|
|
147
147
|
);
|
|
148
|
-
setTranscriptionWorker(worker);
|
|
148
|
+
setTranscriptionWorker(worker, providerName);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
// Fields the worker needs regardless of provider (queue/retention/context).
|
|
@@ -157,8 +157,8 @@ const commonWorkerOpts = {
|
|
|
157
157
|
getContextPredicates: (vault: string) => readVaultConfig(vault)?.transcription?.context,
|
|
158
158
|
};
|
|
159
159
|
|
|
160
|
-
// Provider selection
|
|
161
|
-
//
|
|
160
|
+
// Provider selection uses the shared resolver (local whisper-cpp by default
|
|
161
|
+
// without legacy remote discovery). Bind this identity when wiring the worker.
|
|
162
162
|
const providerName = resolveTranscriptionProviderName();
|
|
163
163
|
if (providerName === "whisper-cpp") {
|
|
164
164
|
// whisper.cpp's prebuilt CLIs — the local, no-Python path that actually
|
|
@@ -289,9 +289,10 @@ if (providerName === "whisper-cpp") {
|
|
|
289
289
|
console.warn(
|
|
290
290
|
"[transcribe] NO transcription provider is reachable — audio attachments " +
|
|
291
291
|
"will be accepted but never transcribed. " +
|
|
292
|
-
`Provider resolved to "${providerName}".
|
|
293
|
-
"`parachute-vault transcription install`
|
|
294
|
-
"
|
|
292
|
+
`Provider resolved to "${providerName}". The standalone Scribe service is retired. ` +
|
|
293
|
+
"For explicit transcription, use `parachute-vault transcription install` and " +
|
|
294
|
+
"check `parachute-vault transcription status`. Automatic uploads require " +
|
|
295
|
+
"the selected worker to be active and ready. " +
|
|
295
296
|
"Set `auto_transcribe.enabled: false` to silence this if you don't want transcription.",
|
|
296
297
|
);
|
|
297
298
|
}
|
|
@@ -756,4 +757,3 @@ setInterval(() => {
|
|
|
756
757
|
}
|
|
757
758
|
void shutdown("STOP_SIGNAL");
|
|
758
759
|
}, STOP_POLL_MS).unref();
|
|
759
|
-
|
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Process-singleton holder for the active TranscriptionWorker (vault#353).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* for an event-driven re-run. Absent the worker (no scribe), the retry
|
|
8
|
-
* endpoint still flips the attachment back to `pending` so the sweep would
|
|
9
|
-
* pick it up — but it'll just sit there until scribe shows up.
|
|
4
|
+
* The server registers its selected local or remote worker at boot after
|
|
5
|
+
* wiring the attachment hook. Retry endpoints use the worker to kick jobs;
|
|
6
|
+
* automatic-upload eligibility also checks the startup provider identity.
|
|
10
7
|
*/
|
|
11
8
|
|
|
12
9
|
import type { TranscriptionWorker } from "./transcription-worker.ts";
|
|
13
10
|
|
|
14
11
|
let activeWorker: TranscriptionWorker | null = null;
|
|
12
|
+
let activeProvider: string | null = null;
|
|
15
13
|
|
|
16
|
-
export function setTranscriptionWorker(worker: TranscriptionWorker | null): void {
|
|
14
|
+
export function setTranscriptionWorker(worker: TranscriptionWorker | null, provider: string | null = null): void {
|
|
17
15
|
activeWorker = worker;
|
|
16
|
+
activeProvider = worker ? provider : null;
|
|
18
17
|
}
|
|
19
18
|
|
|
19
|
+
/** Provider bound at worker startup, not a newly edited environment value. */
|
|
20
|
+
export function getTranscriptionWorkerProvider(): string | null { return activeProvider; }
|
|
21
|
+
|
|
20
22
|
export function getTranscriptionWorker(): TranscriptionWorker | null {
|
|
21
23
|
return activeWorker;
|
|
22
24
|
}
|
|
@@ -64,7 +64,7 @@ test("P9/P18 compacted REST and MCP versions roundtrip with exact keys", async (
|
|
|
64
64
|
expect(mcp).toEqual(get.body);
|
|
65
65
|
const ml = await query()({ versions: { note_id: n.id, limit: 2 } }) as any;
|
|
66
66
|
expect(ml.total).toBe(list.body.total);
|
|
67
|
-
const restore = await call(`/notes/${n.id}/restore`, "POST", { version_ix: 0 });
|
|
67
|
+
const restore = await call(`/notes/${n.id}/restore`, "POST", { version_ix: 0, if_updated_at: (await store.getNote(n.id))!.updatedAt });
|
|
68
68
|
expect(restore.status).toBe(200);
|
|
69
69
|
expect((await store.getNote(n.id))!.content).toBe(before.content);
|
|
70
70
|
});
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
12
12
|
import { generateScopedMcpTools } from "./mcp-tools.ts";
|
|
13
13
|
import { runDoctorScan } from "../core/src/doctor.ts";
|
|
14
|
+
import { applyImportedNote, beginImportRun, importTargetDigest } from "../core/src/history-import.ts";
|
|
15
|
+
import { resolveHistoryPolicy } from "../core/src/history.ts";
|
|
14
16
|
let taskDir: string,
|
|
15
17
|
savedHome: string | undefined,
|
|
16
18
|
store: ReturnType<typeof getVaultStore>;
|
|
@@ -78,9 +80,56 @@ async function fixture() {
|
|
|
78
80
|
await store.updateNote(n.id, { content: "two" });
|
|
79
81
|
return n;
|
|
80
82
|
}
|
|
83
|
+
test("tag-scoped REST and MCP history omit provenance without changing stored rows", async () => {
|
|
84
|
+
const n = await store.createNote("original", { path: "history-visible", tags: ["journal"] });
|
|
85
|
+
await store.updateNote(n.id, { content: "current", actor: "PRIVATE_EDITOR", via: "PRIVATE_INTERFACE" });
|
|
86
|
+
const run = { run_id: "visibility", source_fingerprint: "source", tip: "tip", options_digest: "options" };
|
|
87
|
+
beginImportRun(store.db, run);
|
|
88
|
+
applyImportedNote(store.db, { run, noteId: n.id, targetDigest: importTargetDigest(store.db, n.id),
|
|
89
|
+
policy: resolveHistoryPolicy({ min_versions: 20 }), now: Date.now(),
|
|
90
|
+
observations: [{ content: "archive", path: "old/path", metadata: {}, extension: "md", created_at: null,
|
|
91
|
+
observed_at: new Date().toISOString(), commit: "commit", blob: "blob" }] });
|
|
92
|
+
const before = await store.listNoteVersions(n.id);
|
|
93
|
+
const full = (await call(n.id, "/versions")).body;
|
|
94
|
+
expect(full.versions[0]).toMatchObject({ actor: "PRIVATE_EDITOR", via: "PRIVATE_INTERFACE" });
|
|
95
|
+
for (const scoped of [false, true]) {
|
|
96
|
+
const rest = (await call(n.id, "/versions", "GET", undefined, scoped)).body;
|
|
97
|
+
const mcp = await query(scoped ? ["journal"] : null)({ versions: { note_id: n.id } });
|
|
98
|
+
expect(mcp).toEqual(rest);
|
|
99
|
+
expect(rest.total).toBe(2);
|
|
100
|
+
expect(rest.versions[0].version_ix).toBe(0);
|
|
101
|
+
expect(rest.versions[1]).toMatchObject({ origin: "git-import", import_ix: 0 });
|
|
102
|
+
for (const row of rest.versions) {
|
|
103
|
+
expect(Object.hasOwn(row, "actor")).toBe(!scoped);
|
|
104
|
+
expect(Object.hasOwn(row, "via")).toBe(!scoped);
|
|
105
|
+
}
|
|
106
|
+
for (const [path, selector, content] of [
|
|
107
|
+
["/versions/0", { version_ix: 0 }, "original"],
|
|
108
|
+
["/imports/0", { origin: "git-import", import_ix: 0 }, "archive"],
|
|
109
|
+
] as const) {
|
|
110
|
+
const one = await call(n.id, path, "GET", undefined, scoped);
|
|
111
|
+
expect(one.status).toBe(200);
|
|
112
|
+
expect(one.body.content).toBe(content);
|
|
113
|
+
expect(await query(scoped ? ["journal"] : null)({ versions: { note_id: n.id, ...selector } })).toEqual(one.body);
|
|
114
|
+
expect(Object.hasOwn(one.body, "actor")).toBe(!scoped);
|
|
115
|
+
expect(Object.hasOwn(one.body, "via")).toBe(!scoped);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
expect(await store.listNoteVersions(n.id)).toEqual(before);
|
|
119
|
+
expect((await store.getNote(n.id))!.content).toBe("current");
|
|
120
|
+
// Empty tag lists mean unrestricted in the existing authorization contract.
|
|
121
|
+
expect(await query([])({ versions: { note_id: n.id } })).toEqual(full);
|
|
122
|
+
const denied = await query(["unrelated"])({ versions: { note_id: n.id } });
|
|
123
|
+
expect(denied).toMatchObject({ error_type: "not_found" });
|
|
124
|
+
});
|
|
81
125
|
test("P5 REST restore rejects stale optimistic tokens without capture", async () => {
|
|
82
126
|
const n = await fixture();
|
|
83
127
|
const before = await store.listNoteVersions(n.id);
|
|
128
|
+
for (const extra of [{}, { force: true }]) {
|
|
129
|
+
const missing = await call(n.id, "/restore", "POST", { version_ix: 0, ...extra });
|
|
130
|
+
expect(missing.status).toBe(428);
|
|
131
|
+
expect(missing.body.error_type).toBe("precondition_required");
|
|
132
|
+
}
|
|
84
133
|
const r = await call(n.id, "/restore", "POST", {
|
|
85
134
|
version_ix: 0,
|
|
86
135
|
if_updated_at: "1999-01-01T00:00:00.000Z",
|
|
@@ -393,7 +442,7 @@ test("P24 migration bypass restores, captures once, and emits the bypass log", a
|
|
|
393
442
|
const n = await strictFixture();
|
|
394
443
|
const log = spyOn(console, "warn").mockImplementation(() => {});
|
|
395
444
|
try {
|
|
396
|
-
const r = await call(n.id, "/restore", "POST", { version_ix: 0 }, false, {
|
|
445
|
+
const r = await call(n.id, "/restore", "POST", { version_ix: 0, if_updated_at: (await store.getNote(n.id))!.updatedAt }, false, {
|
|
397
446
|
actor: "migrator",
|
|
398
447
|
via: "api",
|
|
399
448
|
bypassStrict: true,
|