@openparachute/vault 0.7.4 → 0.7.5-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli.ts +113 -1
- package/src/mirror-import-jobs.ts +197 -0
- package/src/mirror-import.test.ts +102 -2
- package/src/mirror-import.ts +182 -22
- package/src/mirror-routes.test.ts +222 -3
- package/src/mirror-routes.ts +228 -86
- package/src/routing.ts +22 -6
- package/src/server.ts +62 -1
- package/src/transcription/install-whisper-cpp.test.ts +228 -0
- package/src/transcription/install-whisper-cpp.ts +219 -0
- package/src/transcription/install-whisper-exec.test.ts +244 -0
- package/src/transcription/install-whisper-exec.ts +237 -0
- package/src/transcription/models.test.ts +87 -0
- package/src/transcription/models.ts +191 -0
- package/src/transcription/providers/whisper-cpp.test.ts +218 -0
- package/src/transcription/providers/whisper-cpp.ts +241 -0
- package/src/transcription/resolve-binary.test.ts +131 -0
- package/src/transcription/resolve-binary.ts +111 -0
- package/src/transcription/select.ts +26 -3
- package/web/ui/dist/assets/index-CD4kPSY9.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-NvwxfZcu.js +0 -61
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -175,6 +175,21 @@ import {
|
|
|
175
175
|
type CliBuildResult,
|
|
176
176
|
} from "./transcription/build.ts";
|
|
177
177
|
import { downloadTo } from "./transcription/download.ts";
|
|
178
|
+
import {
|
|
179
|
+
describeWhisperPlan,
|
|
180
|
+
planWhisperInstall,
|
|
181
|
+
} from "./transcription/install-whisper-cpp.ts";
|
|
182
|
+
import {
|
|
183
|
+
ensureBinaries,
|
|
184
|
+
ensureModel,
|
|
185
|
+
verifyTranscription,
|
|
186
|
+
} from "./transcription/install-whisper-exec.ts";
|
|
187
|
+
import {
|
|
188
|
+
binaryNameFor,
|
|
189
|
+
candidateBinDirs,
|
|
190
|
+
resolveCliBinary,
|
|
191
|
+
resolveFfmpeg,
|
|
192
|
+
} from "./transcription/resolve-binary.ts";
|
|
178
193
|
import { selectDefaultProvider, NOMINAL_SLACK_GB, type TierPlan } from "./transcription/tiers.ts";
|
|
179
194
|
import {
|
|
180
195
|
PYTHON_PROVIDERS,
|
|
@@ -3605,6 +3620,93 @@ function printTranscriptionPlan(plan: InstallPlan): void {
|
|
|
3605
3620
|
console.log(` ~${m.approxSizeMb}MB download, ~${m.approxRuntimeGb}GB peak RAM while transcribing`);
|
|
3606
3621
|
}
|
|
3607
3622
|
|
|
3623
|
+
|
|
3624
|
+
/**
|
|
3625
|
+
* `parachute-vault transcription install` — the whisper.cpp path.
|
|
3626
|
+
*
|
|
3627
|
+
* Plan → binaries → model → VERIFY → activate. The verify step is the
|
|
3628
|
+
* load-bearing one: the provider this replaces was activated by an install
|
|
3629
|
+
* that never checked whether what it configured could run, which is how
|
|
3630
|
+
* `TRANSCRIPTION_PROVIDER` came to point at a CLI that has never existed.
|
|
3631
|
+
* `TRANSCRIPTION_PROVIDER` flips only after a real CLI transcribes a real
|
|
3632
|
+
* file, so an install that reports success is one that works.
|
|
3633
|
+
*/
|
|
3634
|
+
async function runWhisperCppInstall(opts: {
|
|
3635
|
+
modelId?: string;
|
|
3636
|
+
dryRun: boolean;
|
|
3637
|
+
yes: boolean;
|
|
3638
|
+
}): Promise<void> {
|
|
3639
|
+
const plan = planWhisperInstall(opts.modelId, {
|
|
3640
|
+
resolveExisting: (engine) => resolveCliBinary(engine),
|
|
3641
|
+
});
|
|
3642
|
+
for (const line of describeWhisperPlan(plan)) console.log(line);
|
|
3643
|
+
|
|
3644
|
+
if (!plan.supported) {
|
|
3645
|
+
console.error("\nCan't install automatically on this host — see above.");
|
|
3646
|
+
process.exit(1);
|
|
3647
|
+
}
|
|
3648
|
+
if (opts.dryRun) {
|
|
3649
|
+
console.log("\n(dry run — nothing downloaded or changed)");
|
|
3650
|
+
return;
|
|
3651
|
+
}
|
|
3652
|
+
if (!opts.yes) {
|
|
3653
|
+
// Bun's global `confirm()` reads stdin. In a non-TTY — which is exactly
|
|
3654
|
+
// how the unified setup script and any CI invocation call this — it would
|
|
3655
|
+
// block forever, so require an explicit --yes there instead of hanging.
|
|
3656
|
+
if (!process.stdin.isTTY) {
|
|
3657
|
+
console.error(
|
|
3658
|
+
"\nNot a terminal — re-run with --yes to install without confirmation.",
|
|
3659
|
+
);
|
|
3660
|
+
process.exit(1);
|
|
3661
|
+
}
|
|
3662
|
+
if (!confirm("\nProceed?")) {
|
|
3663
|
+
console.log("Cancelled.");
|
|
3664
|
+
return;
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
|
|
3668
|
+
const log = (l: string) => console.log(` ${l}`);
|
|
3669
|
+
|
|
3670
|
+
const bin = await ensureBinaries(plan, { log });
|
|
3671
|
+
console.log(`${bin.ok ? "✓" : "✗"} ${bin.message}`);
|
|
3672
|
+
if (!bin.ok) process.exit(1);
|
|
3673
|
+
|
|
3674
|
+
const model = await ensureModel(plan, { log });
|
|
3675
|
+
console.log(`${model.ok ? "✓" : "✗"} ${model.message}`);
|
|
3676
|
+
if (!model.ok) process.exit(1);
|
|
3677
|
+
|
|
3678
|
+
// Re-resolve AFTER install — a brew install just changed what's on disk.
|
|
3679
|
+
const binPath = resolveCliBinary(plan.model.engine);
|
|
3680
|
+
if (!binPath) {
|
|
3681
|
+
console.error(
|
|
3682
|
+
`✗ ${binaryNameFor(plan.model.engine)} still isn't resolvable after install. ` +
|
|
3683
|
+
`Searched: ${candidateBinDirs().slice(0, 5).join(", ")}`,
|
|
3684
|
+
);
|
|
3685
|
+
process.exit(1);
|
|
3686
|
+
}
|
|
3687
|
+
|
|
3688
|
+
const verified = await verifyTranscription(plan, binPath, { log });
|
|
3689
|
+
console.log(`${verified.ok ? "✓" : "✗"} ${verified.message}`);
|
|
3690
|
+
if (!verified.ok) {
|
|
3691
|
+
console.error("\nNot activating — an install that can't transcribe isn't installed.");
|
|
3692
|
+
process.exit(1);
|
|
3693
|
+
}
|
|
3694
|
+
|
|
3695
|
+
setEnvVar("TRANSCRIPTION_PROVIDER", "whisper-cpp");
|
|
3696
|
+
setEnvVar("TRANSCRIPTION_MODEL", plan.model.id);
|
|
3697
|
+
console.log(`\n✓ Activated: whisper-cpp with ${plan.model.label}.`);
|
|
3698
|
+
|
|
3699
|
+
if (!resolveFfmpeg()) {
|
|
3700
|
+
console.log(
|
|
3701
|
+
"\n! ffmpeg isn't installed. Audio has to be transcoded to 16 kHz mono WAV before\n" +
|
|
3702
|
+
" transcription, so voice memos will fail until you install it:\n" +
|
|
3703
|
+
" macOS: brew install ffmpeg\n" +
|
|
3704
|
+
" Debian: sudo apt install ffmpeg",
|
|
3705
|
+
);
|
|
3706
|
+
}
|
|
3707
|
+
console.log("\nRestart the vault to apply (`parachute restart vault`).");
|
|
3708
|
+
}
|
|
3709
|
+
|
|
3608
3710
|
async function cmdTranscriptionInstall(args: string[]) {
|
|
3609
3711
|
const dryRun = args.includes("--dry-run") || args.includes("--plan");
|
|
3610
3712
|
const force = args.includes("--force");
|
|
@@ -3612,6 +3714,16 @@ async function cmdTranscriptionInstall(args: string[]) {
|
|
|
3612
3714
|
const overrideModel = takeArgValue(args, "--model").value;
|
|
3613
3715
|
const providerArg = takeArgValue(args, "--provider").value;
|
|
3614
3716
|
|
|
3717
|
+
// whisper-cpp is the local path now, and the DEFAULT when no --provider is
|
|
3718
|
+
// given. The legacy tier table below still serves an explicit
|
|
3719
|
+
// `--provider transcribe-cpp|parakeet-mlx|onnx-asr`, but nothing routes
|
|
3720
|
+
// there by default any more: transcribe-cpp's CLI has never shipped, and
|
|
3721
|
+
// the Python providers need a venv plus a multi-GB model.
|
|
3722
|
+
if (!providerArg || providerArg === "whisper-cpp") {
|
|
3723
|
+
await runWhisperCppInstall({ modelId: overrideModel, dryRun, yes });
|
|
3724
|
+
return;
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3615
3727
|
if (providerArg === "scribe-http") {
|
|
3616
3728
|
// Just flip config back to the remote provider — no download.
|
|
3617
3729
|
if (dryRun) {
|
|
@@ -3628,7 +3740,7 @@ async function cmdTranscriptionInstall(args: string[]) {
|
|
|
3628
3740
|
if (providerArg) {
|
|
3629
3741
|
if (!["transcribe-cpp", "parakeet-mlx", "onnx-asr"].includes(providerArg)) {
|
|
3630
3742
|
console.error(
|
|
3631
|
-
`Unknown --provider "${providerArg}". Valid: transcribe-cpp, parakeet-mlx, onnx-asr, scribe-http.`,
|
|
3743
|
+
`Unknown --provider "${providerArg}". Valid: whisper-cpp (default), transcribe-cpp, parakeet-mlx, onnx-asr, scribe-http.`,
|
|
3632
3744
|
);
|
|
3633
3745
|
process.exit(1);
|
|
3634
3746
|
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process job registry for git imports (vault#640).
|
|
3
|
+
*
|
|
4
|
+
* **Why this exists.** `POST /vault/<name>/.parachute/mirror/import` used to
|
|
5
|
+
* run the whole clone-and-import inside the request and return the result.
|
|
6
|
+
* That shape has a hard ceiling that has nothing to do with how long the work
|
|
7
|
+
* legitimately takes:
|
|
8
|
+
*
|
|
9
|
+
* - hub proxies the vault behind `Bun.serve({ idleTimeout: 255 })`, so any
|
|
10
|
+
* import past ~4 minutes dies in the proxy no matter what vault does;
|
|
11
|
+
* - browsers and intermediate proxies impose their own limits;
|
|
12
|
+
* - a dropped connection lost the import's outcome entirely, even when the
|
|
13
|
+
* import itself had succeeded.
|
|
14
|
+
*
|
|
15
|
+
* So the old handler papered over it with a 60s clone timeout and a docstring
|
|
16
|
+
* promising "if/when bigger vaults arrive we promote to async polling." They
|
|
17
|
+
* arrived. This is that promotion.
|
|
18
|
+
*
|
|
19
|
+
* **Shape.** POST starts a job and returns `202 { job_id }` immediately. The
|
|
20
|
+
* work continues on the server independent of the request that started it.
|
|
21
|
+
* `GET .../mirror/import/<job_id>` returns the current record; the SPA polls
|
|
22
|
+
* it for stage + progress and renders the terminal state.
|
|
23
|
+
*
|
|
24
|
+
* **Deliberately in-memory.** Jobs do not survive a vault restart, and that's
|
|
25
|
+
* the honest design rather than a shortcut: the work itself can't survive a
|
|
26
|
+
* restart either (the clone lives in a tempdir, the importer holds an open
|
|
27
|
+
* store handle), so a persisted "running" row would only ever resurrect as a
|
|
28
|
+
* lie. A restart mid-import surfaces as `job_not_found`, which the SPA reads
|
|
29
|
+
* as "the server restarted — check your vault and retry." Whatever the
|
|
30
|
+
* importer had already committed is committed; merge mode makes a retry safe.
|
|
31
|
+
*
|
|
32
|
+
* **Concurrency.** One running import per vault, enforced here so the 409
|
|
33
|
+
* lands before a tempdir is created. `cloneAndImport` keeps its own inFlight
|
|
34
|
+
* guard as the inner belt for direct (CLI / test) callers.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import type {
|
|
38
|
+
ImportProgress,
|
|
39
|
+
ImportResult,
|
|
40
|
+
ImportStage,
|
|
41
|
+
} from "./mirror-import.ts";
|
|
42
|
+
|
|
43
|
+
/** How long a finished job stays queryable before GC. */
|
|
44
|
+
export const FINISHED_JOB_TTL_MS = 60 * 60_000;
|
|
45
|
+
|
|
46
|
+
/** Terminal + non-terminal job states. */
|
|
47
|
+
export type ImportJobStatus = "running" | "succeeded" | "failed";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Error detail for a failed job. Mirrors the `error_type` vocabulary the
|
|
51
|
+
* synchronous handler used to return, so the SPA's error branching is
|
|
52
|
+
* unchanged — it just reads them off the job record instead of the POST
|
|
53
|
+
* response.
|
|
54
|
+
*/
|
|
55
|
+
export interface ImportJobError {
|
|
56
|
+
error_type:
|
|
57
|
+
| "git_not_installed"
|
|
58
|
+
| "concurrent_import"
|
|
59
|
+
| "not_a_vault_export"
|
|
60
|
+
| "clone_failed"
|
|
61
|
+
| "internal";
|
|
62
|
+
message: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The record the status endpoint serves. */
|
|
66
|
+
export interface ImportJob {
|
|
67
|
+
job_id: string;
|
|
68
|
+
vault_name: string;
|
|
69
|
+
status: ImportJobStatus;
|
|
70
|
+
stage: ImportStage;
|
|
71
|
+
/** Latest progress line for the current stage, when there is one. */
|
|
72
|
+
detail?: string;
|
|
73
|
+
started_at: string;
|
|
74
|
+
updated_at: string;
|
|
75
|
+
finished_at?: string;
|
|
76
|
+
/** Present iff `status === "succeeded"`. */
|
|
77
|
+
result?: ImportResult;
|
|
78
|
+
/** Present iff `status === "failed"`. */
|
|
79
|
+
error?: ImportJobError;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Thrown by `startImportJob` when this vault already has one running. */
|
|
83
|
+
export class ImportJobConflictError extends Error {
|
|
84
|
+
constructor(public readonly vaultName: string) {
|
|
85
|
+
super(
|
|
86
|
+
`An import is already running for vault "${vaultName}". Wait for it to finish, or reload to watch its progress.`,
|
|
87
|
+
);
|
|
88
|
+
this.name = "ImportJobConflictError";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** job_id → record. */
|
|
93
|
+
const jobs = new Map<string, ImportJob>();
|
|
94
|
+
/** vault name → job_id of the RUNNING job, if any. */
|
|
95
|
+
const running = new Map<string, string>();
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Drop finished jobs past their TTL. Called opportunistically on every
|
|
99
|
+
* create/read rather than on a timer — a timer would keep the event loop
|
|
100
|
+
* alive and there is no correctness need for prompt collection.
|
|
101
|
+
*/
|
|
102
|
+
function gc(now: number): void {
|
|
103
|
+
for (const [id, job] of jobs) {
|
|
104
|
+
if (job.status === "running") continue;
|
|
105
|
+
const finished = job.finished_at ? Date.parse(job.finished_at) : 0;
|
|
106
|
+
if (Number.isFinite(finished) && now - finished > FINISHED_JOB_TTL_MS) {
|
|
107
|
+
jobs.delete(id);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The running job for a vault, or undefined. */
|
|
113
|
+
export function getRunningImportJob(vaultName: string): ImportJob | undefined {
|
|
114
|
+
const id = running.get(vaultName);
|
|
115
|
+
return id ? jobs.get(id) : undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Look up a job by id, scoped to a vault. The vault scope matters: the route
|
|
120
|
+
* is authorized against `vault:<name>:admin`, so an admin of vault A must not
|
|
121
|
+
* be able to read vault B's import record by guessing an id.
|
|
122
|
+
*/
|
|
123
|
+
export function getImportJob(
|
|
124
|
+
vaultName: string,
|
|
125
|
+
jobId: string,
|
|
126
|
+
): ImportJob | undefined {
|
|
127
|
+
gc(Date.now());
|
|
128
|
+
const job = jobs.get(jobId);
|
|
129
|
+
if (!job || job.vault_name !== vaultName) return undefined;
|
|
130
|
+
return job;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Register a job and start `work` on it. Returns the initial record
|
|
135
|
+
* synchronously so the route can 202 immediately; `work` runs detached.
|
|
136
|
+
*
|
|
137
|
+
* `work` receives a progress sink to call as it moves through stages. Its
|
|
138
|
+
* resolved value becomes `result`; a throw becomes `error` via `classify`.
|
|
139
|
+
*/
|
|
140
|
+
export function startImportJob(
|
|
141
|
+
vaultName: string,
|
|
142
|
+
work: (onProgress: (update: ImportProgress) => void) => Promise<ImportResult>,
|
|
143
|
+
classify: (err: unknown) => ImportJobError,
|
|
144
|
+
): ImportJob {
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
gc(now);
|
|
147
|
+
if (running.has(vaultName)) {
|
|
148
|
+
throw new ImportJobConflictError(vaultName);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const nowIso = new Date(now).toISOString();
|
|
152
|
+
const job: ImportJob = {
|
|
153
|
+
job_id: crypto.randomUUID(),
|
|
154
|
+
vault_name: vaultName,
|
|
155
|
+
status: "running",
|
|
156
|
+
stage: "cloning",
|
|
157
|
+
started_at: nowIso,
|
|
158
|
+
updated_at: nowIso,
|
|
159
|
+
};
|
|
160
|
+
jobs.set(job.job_id, job);
|
|
161
|
+
running.set(vaultName, job.job_id);
|
|
162
|
+
|
|
163
|
+
const onProgress = (update: ImportProgress) => {
|
|
164
|
+
// A tick that arrives after the job finished (a late stderr line racing
|
|
165
|
+
// the exit) must not resurrect a terminal record.
|
|
166
|
+
if (job.status !== "running") return;
|
|
167
|
+
job.stage = update.stage;
|
|
168
|
+
if (update.detail !== undefined) job.detail = update.detail;
|
|
169
|
+
else delete job.detail;
|
|
170
|
+
job.updated_at = new Date().toISOString();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const finish = (patch: Partial<ImportJob>) => {
|
|
174
|
+
Object.assign(job, patch);
|
|
175
|
+
job.finished_at = new Date().toISOString();
|
|
176
|
+
job.updated_at = job.finished_at;
|
|
177
|
+
delete job.detail;
|
|
178
|
+
running.delete(vaultName);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// Detached on purpose — the response has already been sent by the time this
|
|
182
|
+
// settles. Every rejection path is funnelled through `classify` so an
|
|
183
|
+
// unexpected throw becomes a readable job error rather than an unhandled
|
|
184
|
+
// rejection that leaves the job "running" forever.
|
|
185
|
+
void work(onProgress).then(
|
|
186
|
+
(result) => finish({ status: "succeeded", result }),
|
|
187
|
+
(err) => finish({ status: "failed", error: classify(err) }),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
return job;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Test seam: forget every job. */
|
|
194
|
+
export function _resetImportJobsForTest(): void {
|
|
195
|
+
jobs.clear();
|
|
196
|
+
running.clear();
|
|
197
|
+
}
|
|
@@ -24,6 +24,7 @@ import { SqliteStore } from "../core/src/store.ts";
|
|
|
24
24
|
import { exportVaultToDir } from "../core/src/portable-md.ts";
|
|
25
25
|
import {
|
|
26
26
|
CloneFailedError,
|
|
27
|
+
DEFAULT_CLONE_STALL_TIMEOUT_MS,
|
|
27
28
|
ImportConflictError,
|
|
28
29
|
NotAVaultExportError,
|
|
29
30
|
_isImportInFlight,
|
|
@@ -437,7 +438,7 @@ describe("cloneAndImport — failures", () => {
|
|
|
437
438
|
}
|
|
438
439
|
});
|
|
439
440
|
|
|
440
|
-
test("clone timeout → CloneFailedError
|
|
441
|
+
test("absolute clone timeout → CloneFailedError naming the limit", async () => {
|
|
441
442
|
await expect(
|
|
442
443
|
cloneAndImport({
|
|
443
444
|
vaultName: "default",
|
|
@@ -449,7 +450,106 @@ describe("cloneAndImport — failures", () => {
|
|
|
449
450
|
spawn: spawnCloneTimeout,
|
|
450
451
|
cloneTimeoutMs: 100,
|
|
451
452
|
}),
|
|
452
|
-
).rejects.toThrow(/
|
|
453
|
+
).rejects.toThrow(/exceeded its .*limit/);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// vault#640 — a stalled clone and a slow one are different failures and get
|
|
457
|
+
// different copy. The old code could only say "timed out after 60s", which
|
|
458
|
+
// was actively misleading on a big vault: nothing was wrong except that the
|
|
459
|
+
// vault was large.
|
|
460
|
+
test("stalled clone → CloneFailedError says STALLED, not 'too slow'", async () => {
|
|
461
|
+
const spawnStalled: GitSpawn = async () => ({
|
|
462
|
+
exitCode: 143,
|
|
463
|
+
stderr: "",
|
|
464
|
+
timedOut: true,
|
|
465
|
+
stalled: true,
|
|
466
|
+
});
|
|
467
|
+
await expect(
|
|
468
|
+
cloneAndImport({
|
|
469
|
+
vaultName: "default",
|
|
470
|
+
remoteUrl: "https://github.com/owner/repo.git",
|
|
471
|
+
auth: { kind: "none" },
|
|
472
|
+
mode: "merge",
|
|
473
|
+
store,
|
|
474
|
+
assetsDir,
|
|
475
|
+
spawn: spawnStalled,
|
|
476
|
+
cloneStallTimeoutMs: 600_000,
|
|
477
|
+
}),
|
|
478
|
+
).rejects.toThrow(/stalled — no progress for 10 minutes/);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// The regression that made this whole change necessary: a clone that takes
|
|
482
|
+
// longer than a minute must NOT be killed. Previously `cloneTimeoutMs`
|
|
483
|
+
// defaulted to 60s, so any vault big enough to matter failed here.
|
|
484
|
+
test("no absolute timeout by default — a long clone is allowed to finish", async () => {
|
|
485
|
+
const localFixture = await buildExportFixture();
|
|
486
|
+
let observedTimeout: number | undefined;
|
|
487
|
+
let observedStall: number | undefined;
|
|
488
|
+
const spawnSlowButFine: GitSpawn = async (argv, options) => {
|
|
489
|
+
observedTimeout = options.timeoutMs;
|
|
490
|
+
observedStall = options.stallTimeoutMs;
|
|
491
|
+
const dest = argv[argv.length - 1]!;
|
|
492
|
+
cpSync(localFixture, dest, { recursive: true });
|
|
493
|
+
return { exitCode: 0, stderr: "", timedOut: false };
|
|
494
|
+
};
|
|
495
|
+
const result = await cloneAndImport({
|
|
496
|
+
vaultName: "default",
|
|
497
|
+
remoteUrl: "https://github.com/owner/repo.git",
|
|
498
|
+
auth: { kind: "none" },
|
|
499
|
+
mode: "merge",
|
|
500
|
+
store,
|
|
501
|
+
assetsDir,
|
|
502
|
+
spawn: spawnSlowButFine,
|
|
503
|
+
});
|
|
504
|
+
expect(result.notes_imported).toBeGreaterThan(0);
|
|
505
|
+
// 0 == disabled. The stall bound is what guards a wedged clone.
|
|
506
|
+
expect(observedTimeout).toBe(0);
|
|
507
|
+
expect(observedStall).toBe(DEFAULT_CLONE_STALL_TIMEOUT_MS);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("clone runs with --progress so the stall timer has something to watch", async () => {
|
|
511
|
+
const localFixture = await buildExportFixture();
|
|
512
|
+
let argvSeen: string[] = [];
|
|
513
|
+
const spawnCapture: GitSpawn = async (argv) => {
|
|
514
|
+
argvSeen = argv;
|
|
515
|
+
const dest = argv[argv.length - 1]!;
|
|
516
|
+
cpSync(localFixture, dest, { recursive: true });
|
|
517
|
+
return { exitCode: 0, stderr: "", timedOut: false };
|
|
518
|
+
};
|
|
519
|
+
await cloneAndImport({
|
|
520
|
+
vaultName: "default",
|
|
521
|
+
remoteUrl: "https://github.com/owner/repo.git",
|
|
522
|
+
auth: { kind: "none" },
|
|
523
|
+
mode: "merge",
|
|
524
|
+
store,
|
|
525
|
+
assetsDir,
|
|
526
|
+
spawn: spawnCapture,
|
|
527
|
+
});
|
|
528
|
+
expect(argvSeen).toContain("--progress");
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("onProgress reports stage transitions and clone detail", async () => {
|
|
532
|
+
const localFixture = await buildExportFixture();
|
|
533
|
+
const seen: string[] = [];
|
|
534
|
+
const spawnWithProgress: GitSpawn = async (argv, options) => {
|
|
535
|
+
options.onProgress?.("Receiving objects: 47% (470/1000)");
|
|
536
|
+
const dest = argv[argv.length - 1]!;
|
|
537
|
+
cpSync(localFixture, dest, { recursive: true });
|
|
538
|
+
return { exitCode: 0, stderr: "", timedOut: false };
|
|
539
|
+
};
|
|
540
|
+
await cloneAndImport({
|
|
541
|
+
vaultName: "default",
|
|
542
|
+
remoteUrl: "https://github.com/owner/repo.git",
|
|
543
|
+
auth: { kind: "none" },
|
|
544
|
+
mode: "merge",
|
|
545
|
+
store,
|
|
546
|
+
assetsDir,
|
|
547
|
+
spawn: spawnWithProgress,
|
|
548
|
+
onProgress: (u) => seen.push(`${u.stage}:${u.detail ?? ""}`),
|
|
549
|
+
});
|
|
550
|
+
expect(seen).toContain("cloning:");
|
|
551
|
+
expect(seen).toContain("cloning:Receiving objects: 47% (470/1000)");
|
|
552
|
+
expect(seen).toContain("importing:");
|
|
453
553
|
});
|
|
454
554
|
|
|
455
555
|
test("clone target lacks .parachute/vault.yaml → NotAVaultExportError", async () => {
|