@openparachute/vault 0.7.9-rc.5 → 0.7.9-rc.7
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 +4 -0
- package/core/src/history-import.test.ts +82 -0
- package/core/src/history-import.ts +157 -0
- package/core/src/history.test.ts +57 -0
- package/core/src/history.ts +8 -3
- package/core/src/mcp-manifest.ts +6 -0
- package/core/src/mcp.ts +11 -4
- package/core/src/schema-v29-note-versions.test.ts +1 -1
- package/core/src/schema-v30-note-deltas.test.ts +1 -1
- package/core/src/schema-v31-history-import.test.ts +25 -0
- package/core/src/schema.ts +33 -1
- 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/cli.ts +10 -1
- package/src/history-import-mcp.test.ts +94 -0
- package/src/history-import-routes.test.ts +91 -0
- package/src/history-import.test.ts +318 -0
- package/src/history-import.ts +482 -0
- package/src/mcp-http.ts +13 -0
- package/src/mcp-tools.ts +7 -1
- package/src/mirror-config.ts +38 -0
- package/src/mirror-manager.ts +6 -0
- package/src/mirror-retirement.test.ts +79 -0
- package/src/mirror-routes.ts +12 -2
- package/src/routes.ts +28 -9
- package/src/server.ts +40 -21
- package/src/transcription-registry.ts +9 -7
- package/src/vault-store.ts +2 -0
- 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; }
|
package/src/cli.ts
CHANGED
|
@@ -296,6 +296,11 @@ switch (command) {
|
|
|
296
296
|
case "backup":
|
|
297
297
|
await cmdBackup(cmdArgs);
|
|
298
298
|
break;
|
|
299
|
+
case "history-import": {
|
|
300
|
+
const { runHistoryImport } = await import("./history-import.ts");
|
|
301
|
+
console.log(JSON.stringify(await runHistoryImport(cmdArgs), null, 2));
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
299
304
|
case "import":
|
|
300
305
|
await cmdImport(cmdArgs);
|
|
301
306
|
break;
|
|
@@ -4815,7 +4820,7 @@ async function createVault(
|
|
|
4815
4820
|
// git-less box leaves the config written but inactive + logs an actionable
|
|
4816
4821
|
// hint — it must NEVER fail the vault create. Create-time ONLY: existing
|
|
4817
4822
|
// vaults are never retroactively migrated.
|
|
4818
|
-
if (shouldEnableCreateTimeMirror(opts)) {
|
|
4823
|
+
if (shouldEnableCreateTimeMirror(opts) && (await import("./mirror-config.ts")).readHistoryMirrorPhase(name) === "active") {
|
|
4819
4824
|
const mirrorConfig = historyPresetMirrorConfig();
|
|
4820
4825
|
writeMirrorConfigForVault(name, mirrorConfig);
|
|
4821
4826
|
const mirrorPath = resolveMirrorPath(vaultDir(name), mirrorConfig);
|
|
@@ -5213,6 +5218,10 @@ Import/Export:
|
|
|
5213
5218
|
--git-push to push after commit)
|
|
5214
5219
|
|
|
5215
5220
|
History:
|
|
5221
|
+
parachute-vault history-import plan|prepare|apply|retire|cancel --vault <name>
|
|
5222
|
+
Import a local git archive into note history;
|
|
5223
|
+
plan/prepare: --selections <file> for audited duplicates;
|
|
5224
|
+
see UPGRADING.md for offline cutover and recovery.
|
|
5216
5225
|
parachute-vault history [--vault <name>] Show the vault's git write history (the
|
|
5217
5226
|
mirror is git-backed — one file per note,
|
|
5218
5227
|
so git log IS a tamper-evident history)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { handleMcp } from "./mcp-http.ts";
|
|
3
|
+
import type { McpToolDef } from "../core/src/mcp.ts";
|
|
4
|
+
|
|
5
|
+
async function errorEnvelope(fields: Record<string, unknown>) {
|
|
6
|
+
const tool: McpToolDef = {
|
|
7
|
+
name: "query-notes", description: "serializer boundary fixture", requiredVerb: "read",
|
|
8
|
+
inputSchema: { type: "object" },
|
|
9
|
+
execute() { throw Object.assign(new Error("Unrecoverable history"), fields); },
|
|
10
|
+
};
|
|
11
|
+
const request = new Request("http://localhost/vault/import-test/mcp", {
|
|
12
|
+
method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
13
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool.name, arguments: {} } }),
|
|
14
|
+
});
|
|
15
|
+
const response = await handleMcp(request, () => [tool], "import-test", "import-test", {
|
|
16
|
+
permission: "full", scopes: ["vault:import-test:read"], legacyDerived: false,
|
|
17
|
+
scoped_tags: null, vault_name: null, caller_jti: null, actor: "test", via: "api",
|
|
18
|
+
}, "");
|
|
19
|
+
return await response.json() as any;
|
|
20
|
+
}
|
|
21
|
+
test("HTTP MCP forwards valid imported history references only on the dedicated error branch", async () => {
|
|
22
|
+
const fields = { code: "HISTORY_UNRECOVERABLE", error_type: "history_unrecoverable", origin: "git-import", import_ix: 0, version_ix: -1 };
|
|
23
|
+
const rpc = await errorEnvelope(fields);
|
|
24
|
+
expect(rpc.result).toBeUndefined();
|
|
25
|
+
expect(rpc.error.code).toBe(-32602);
|
|
26
|
+
expect(rpc.error.data).toEqual({ error_type: "history_unrecoverable", origin: "git-import", import_ix: 0 });
|
|
27
|
+
for (const invalid of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1, "0", null]) {
|
|
28
|
+
const bad = await errorEnvelope({ ...fields, import_ix: invalid });
|
|
29
|
+
expect(bad.result).toBeUndefined();
|
|
30
|
+
expect(bad.error.data).toEqual({ error_type: "history_unrecoverable" });
|
|
31
|
+
}
|
|
32
|
+
const generic = await errorEnvelope({ ...fields, code: "OTHER" });
|
|
33
|
+
expect(generic.error.data).toEqual({ error_type: "history_unrecoverable" });
|
|
34
|
+
const native = await errorEnvelope({ code: "HISTORY_UNRECOVERABLE", error_type: "history_unrecoverable", version_ix: 0, note_id: "note" });
|
|
35
|
+
expect(native.error.data).toEqual({ error_type: "history_unrecoverable" });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
39
|
+
import { tmpdir } from "node:os";
|
|
40
|
+
import { join } from "node:path";
|
|
41
|
+
import { writeVaultConfig } from "./config.ts";
|
|
42
|
+
import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
43
|
+
import { generateScopedMcpTools } from "./mcp-tools.ts";
|
|
44
|
+
import { applyImportedNote, beginImportRun, importTargetDigest } from "../core/src/history-import.ts";
|
|
45
|
+
import { resolveHistoryPolicy } from "../core/src/history.ts";
|
|
46
|
+
|
|
47
|
+
test("HTTP MCP imported get/list/error keeps discriminator and rejects mixed selectors", async () => {
|
|
48
|
+
const saved = process.env.PARACHUTE_HOME;
|
|
49
|
+
const dir = mkdtempSync(join(tmpdir(), "history-import-mcp-"));
|
|
50
|
+
process.env.PARACHUTE_HOME = dir;
|
|
51
|
+
try {
|
|
52
|
+
writeVaultConfig({ name: "import-test", api_keys: [], created_at: new Date().toISOString() });
|
|
53
|
+
const store = getVaultStore("import-test");
|
|
54
|
+
const note = await store.createNote("live");
|
|
55
|
+
const run = { run_id: "test", source_fingerprint: "source", tip: "tip", options_digest: "options" };
|
|
56
|
+
beginImportRun(store.db, run);
|
|
57
|
+
applyImportedNote(store.db, {
|
|
58
|
+
run, noteId: note.id, policy: resolveHistoryPolicy({ min_versions: 20 }), now: Date.now(),
|
|
59
|
+
targetDigest: importTargetDigest(store.db, note.id),
|
|
60
|
+
observations: [{ content: "archive", path: null, metadata: {}, extension: "md", created_at: null, observed_at: new Date().toISOString(), commit: "commit", blob: "blob" }],
|
|
61
|
+
});
|
|
62
|
+
const auth = { permission: "full" as const, scopes: ["vault:import-test:read"], legacyDerived: false, scoped_tags: null, vault_name: null, caller_jti: null, actor: "test", via: "api" };
|
|
63
|
+
async function call(selector: Record<string, unknown>) {
|
|
64
|
+
const req = new Request("http://localhost/vault/import-test/mcp", { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "query-notes", arguments: { versions: { note_id: note.id, ...selector } } } }) });
|
|
65
|
+
return await (await handleMcp(req, () => generateScopedMcpTools("import-test", auth), "test", "import-test", auth, "")).json() as any;
|
|
66
|
+
}
|
|
67
|
+
const got = await call({ origin: "git-import", import_ix: 0 });
|
|
68
|
+
const row = JSON.parse(got.result.content[0].text);
|
|
69
|
+
expect(row.content).toBe("archive");
|
|
70
|
+
expect(row.origin).toBe("git-import");
|
|
71
|
+
expect(row.import_ix).toBe(0);
|
|
72
|
+
expect(row.version_ix).toBeUndefined();
|
|
73
|
+
const listed = JSON.parse((await call({})).result.content[0].text);
|
|
74
|
+
expect(listed.versions[0].origin).toBe("git-import");
|
|
75
|
+
for (const selector of [{ origin: "git-import" }, { import_ix: 0 }, { version_ix: 0, origin: "git-import", import_ix: 0 }, { version_ix: -1 }]) {
|
|
76
|
+
const invalid = await call(selector);
|
|
77
|
+
expect(invalid.error).toBeDefined();
|
|
78
|
+
expect(invalid.result).toBeUndefined();
|
|
79
|
+
}
|
|
80
|
+
store.db.exec("UPDATE note_blobs SET encoding='unknown'");
|
|
81
|
+
const broken = await call({ origin: "git-import", import_ix: 0 });
|
|
82
|
+
expect(broken.result).toBeUndefined();
|
|
83
|
+
expect(broken.error.data).toEqual({ error_type: "history_unrecoverable", origin: "git-import", import_ix: 0 });
|
|
84
|
+
expect(broken.error.message).not.toContain("@-1");
|
|
85
|
+
const scoped = generateScopedMcpTools("import-test", { ...auth, scoped_tags: ["journal"] }).find(t => t.name === "query-notes")!;
|
|
86
|
+
const denied = await scoped.execute({ versions: { note_id: note.id, origin: "git-import", import_ix: 0 } });
|
|
87
|
+
expect(denied).toEqual({ error: "Note not found", error_type: "not_found", id: note.id });
|
|
88
|
+
|
|
89
|
+
} finally {
|
|
90
|
+
clearVaultStoreCache();
|
|
91
|
+
if (saved === undefined) delete process.env.PARACHUTE_HOME; else process.env.PARACHUTE_HOME = saved;
|
|
92
|
+
rmSync(dir, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { generateKeyPair, exportJWK, SignJWT } from "jose";
|
|
6
|
+
import { handleMcp } from "./mcp-http.ts";
|
|
7
|
+
import { route } from "./routing.ts";
|
|
8
|
+
import { resetJwksCache, resetRevocationCache } from "./hub-jwt.ts";
|
|
9
|
+
import { writeVaultConfig, readVaultConfig, vaultConfigPath } from "./config.ts";
|
|
10
|
+
import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
11
|
+
import { generateScopedMcpTools } from "./mcp-tools.ts";
|
|
12
|
+
import { BunSqliteStore } from "../core/src/store.ts";
|
|
13
|
+
import * as history from "../core/src/history.ts";
|
|
14
|
+
|
|
15
|
+
let taskDir: string, savedHome: string | undefined, savedOrigin: string | undefined, savedJwks: string | undefined, origin: string;
|
|
16
|
+
let store: ReturnType<typeof getVaultStore>, server: ReturnType<typeof Bun.serve>, privateKey: CryptoKey;
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
savedHome = process.env.PARACHUTE_HOME;
|
|
19
|
+
savedOrigin = process.env.PARACHUTE_HUB_ORIGIN;
|
|
20
|
+
savedJwks = process.env.PARACHUTE_HUB_JWKS_ORIGIN;
|
|
21
|
+
taskDir = mkdtempSync(join(tmpdir(), "vault-compact-"));
|
|
22
|
+
process.env.PARACHUTE_HOME = taskDir;
|
|
23
|
+
writeVaultConfig({ name: "compact", api_keys: [], created_at: new Date().toISOString() });
|
|
24
|
+
store = getVaultStore("compact");
|
|
25
|
+
const keys = await generateKeyPair("RS256", { extractable: true });
|
|
26
|
+
privateKey = keys.privateKey;
|
|
27
|
+
const jwk = await exportJWK(keys.publicKey);
|
|
28
|
+
server = Bun.serve({ port: 0, fetch(req) { return new URL(req.url).pathname === "/.well-known/jwks.json" ? Response.json({ keys: [{ ...jwk, kid: "compact-test", alg: "RS256", use: "sig" }] }) : Response.json({ generated_at: new Date().toISOString(), jtis: [] }); } });
|
|
29
|
+
origin = `http://127.0.0.1:${server.port}`;
|
|
30
|
+
process.env.PARACHUTE_HUB_ORIGIN = origin;
|
|
31
|
+
process.env.PARACHUTE_HUB_JWKS_ORIGIN = origin;
|
|
32
|
+
resetJwksCache();
|
|
33
|
+
resetRevocationCache();
|
|
34
|
+
});
|
|
35
|
+
afterEach(() => { server.stop(true); clearVaultStoreCache(); rmSync(taskDir, { recursive: true, force: true }); for (const [key, value] of [["PARACHUTE_HOME", savedHome], ["PARACHUTE_HUB_ORIGIN", savedOrigin], ["PARACHUTE_HUB_JWKS_ORIGIN", savedJwks]] as const) {
|
|
36
|
+
if (value === undefined)
|
|
37
|
+
delete process.env[key];
|
|
38
|
+
else
|
|
39
|
+
process.env[key] = value;
|
|
40
|
+
} resetJwksCache(); resetRevocationCache(); });
|
|
41
|
+
async function call(path: string, method = "GET", body?: unknown, verb = "admin", scoped = false) {
|
|
42
|
+
const token = await new SignJWT({ scope: `vault:compact:${verb}`, client_id: "compact-test", ...(scoped ? { permissions: { scoped_tags: ["journal"] } } : {}) }).setProtectedHeader({ alg: "RS256", kid: "compact-test" }).setIssuer(origin).setSubject("tester").setAudience("vault.compact").setIssuedAt().setExpirationTime("5m").setJti(crypto.randomUUID()).sign(privateKey);
|
|
43
|
+
const full = `/vault/compact/api${path}`, response = await route(new Request(`http://localhost${full}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }), new URL(`http://localhost${full}`).pathname);
|
|
44
|
+
return { status: response.status, body: await response.json() as any };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
import { applyImportedNote, beginImportRun, importTargetDigest } from "../core/src/history-import.ts";
|
|
48
|
+
test("imported REST references restore by ID or encoded path and preserve native keys", async () => {
|
|
49
|
+
const note = await store.createNote("native", { path: "folder/imported", tags: ["journal"] });
|
|
50
|
+
await store.updateNote(note.id, { content: "current" });
|
|
51
|
+
const run = { run_id: "rest", source_fingerprint: "source", tip: "tip", options_digest: "options" };
|
|
52
|
+
beginImportRun(store.db, run);
|
|
53
|
+
applyImportedNote(store.db, { run, noteId: note.id, targetDigest: importTargetDigest(store.db, note.id), policy: history.resolveHistoryPolicy({ min_versions: 20 }), now: Date.now(), observations: [{ content: "archive", path: "old/path", metadata: { old: true }, extension: "md", created_at: null, observed_at: new Date().toISOString(), commit: "commit", blob: "blob" }] });
|
|
54
|
+
for (const ref of [note.id, encodeURIComponent("folder/imported")]) {
|
|
55
|
+
const got = await call(`/notes/${ref}/imports/0`);
|
|
56
|
+
expect(got.status).toBe(200);
|
|
57
|
+
expect(got.body.content).toBe("archive");
|
|
58
|
+
expect(got.body.origin).toBe("git-import");
|
|
59
|
+
expect(got.body.import_ix).toBe(0);
|
|
60
|
+
expect(got.body.version_ix).toBeUndefined();
|
|
61
|
+
}
|
|
62
|
+
const native = await call(`/notes/${note.id}/versions/0`);
|
|
63
|
+
expect(native.body.version_ix).toBe(0);
|
|
64
|
+
expect(native.body.origin).toBeUndefined();
|
|
65
|
+
const list = await call(`/notes/${note.id}/versions`);
|
|
66
|
+
expect(list.body.versions.map((v: any) => v.version_ix ?? v.origin)).toEqual([0, "git-import"]);
|
|
67
|
+
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
|
+
expect((await call(`/notes/${note.id}/versions/-1`)).status).toBe(400);
|
|
69
|
+
const restored = await call(`/notes/${note.id}/restore`, "POST", { origin: "git-import", import_ix: 0 });
|
|
70
|
+
expect(restored.status).toBe(200);
|
|
71
|
+
expect(restored.body.restored_from).toEqual({ origin: "git-import", import_ix: 0 });
|
|
72
|
+
expect((await store.getNote(note.id))!.content).toBe("archive");
|
|
73
|
+
expect((await store.getNote(note.id))!.path).toBe("folder/imported");
|
|
74
|
+
const missing = await call(`/notes/${note.id}/imports/99`);
|
|
75
|
+
expect(missing.status).toBe(404);
|
|
76
|
+
expect(missing.body).toMatchObject({ origin: "git-import", import_ix: 99 });
|
|
77
|
+
await store.deleteNote(note.id);
|
|
78
|
+
const recreated = await call(`/notes/${note.id}/restore`, "POST", { origin: "git-import", import_ix: 0 });
|
|
79
|
+
expect(recreated.status).toBe(200);
|
|
80
|
+
expect(recreated.body.recreated).toBe(true);
|
|
81
|
+
expect((await store.getNote(note.id))!.content).toBe("archive");
|
|
82
|
+
const hash = store.db.query("SELECT content_hash FROM note_versions WHERE note_id=? AND version_ix=-1").get(note.id) as { content_hash: string };
|
|
83
|
+
store.db.query("UPDATE note_blobs SET encoding='unknown' WHERE hash=?").run(hash.content_hash);
|
|
84
|
+
const broken = await call(`/notes/${note.id}/imports/0`);
|
|
85
|
+
expect(broken.status).toBe(409);
|
|
86
|
+
expect(broken.body).toMatchObject({ origin: "git-import", import_ix: 0, error_type: "history_unrecoverable" });
|
|
87
|
+
expect(JSON.stringify(broken.body)).not.toContain("version_ix");
|
|
88
|
+
expect(JSON.stringify(broken.body)).not.toContain("@-1");
|
|
89
|
+
const privateNote = await store.createNote("private", { tags: ["secret"] });
|
|
90
|
+
expect((await call(`/notes/${privateNote.id}/imports/0`, "GET", undefined, "read", true)).status).toBe(404);
|
|
91
|
+
});
|