@openparachute/vault 0.7.4 → 0.7.5-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -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/models.test.ts +87 -0
- package/src/transcription/models.ts +187 -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
|
@@ -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 () => {
|
package/src/mirror-import.ts
CHANGED
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
* - Creates a temp dir (`os.tmpdir() + /parachute-import-<rand>`).
|
|
19
19
|
* - Resolves the authed clone URL (stored credentials, supplied per-call
|
|
20
20
|
* PAT, or none).
|
|
21
|
-
* - Shells `git clone --depth 1 <authedUrl> <tempDir>` with
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* - Shells `git clone --depth 1 --progress <authedUrl> <tempDir>` with
|
|
22
|
+
* `GIT_TERMINAL_PROMPT=0` so bad credentials fail fast rather than
|
|
23
|
+
* blocking on a stdin prompt. Bounded by a STALL timeout (no progress
|
|
24
|
+
* output for 10 min), not a wall-clock one — see `cloneTimeoutMs`.
|
|
24
25
|
* - Validates the clone looks like a vault export — `.parachute/vault.yaml`
|
|
25
26
|
* must be present. Refuses with a clear error otherwise.
|
|
26
27
|
* - On `mode: "replace"`: wipes notes + tags via `store.deleteNote()` /
|
|
@@ -128,8 +129,28 @@ export interface ImportOpts {
|
|
|
128
129
|
spawn?: GitSpawn;
|
|
129
130
|
/** Override the post-clone import path (test seam — assume cloned dir is a vault export). */
|
|
130
131
|
importer?: typeof importPortableVault;
|
|
131
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* Absolute wall-clock cap on the clone. **Defaults to 0 — disabled.**
|
|
134
|
+
*
|
|
135
|
+
* This used to default to 60s, which is why importing any vault bigger than
|
|
136
|
+
* a demo failed with "git clone timed out after 60s" (vault#640). A clone's
|
|
137
|
+
* duration is a function of vault size and link speed; there is no honest
|
|
138
|
+
* wall-clock number that fits both a 200-note vault and a 40k-note one. The
|
|
139
|
+
* bound that actually distinguishes "big" from "broken" is
|
|
140
|
+
* `cloneStallTimeoutMs` below. Tests set this to force the timeout branch.
|
|
141
|
+
*/
|
|
132
142
|
cloneTimeoutMs?: number;
|
|
143
|
+
/**
|
|
144
|
+
* How long the clone may emit NO progress output before we call it wedged.
|
|
145
|
+
* Defaults to `DEFAULT_CLONE_STALL_TIMEOUT_MS` (10 min); `0` disables.
|
|
146
|
+
*/
|
|
147
|
+
cloneStallTimeoutMs?: number;
|
|
148
|
+
/**
|
|
149
|
+
* Progress sink. Called as the import moves between stages and as git
|
|
150
|
+
* reports clone progress. The job registry wires this to the record the
|
|
151
|
+
* status endpoint serves; direct callers can omit it.
|
|
152
|
+
*/
|
|
153
|
+
onProgress?: (update: ImportProgress) => void;
|
|
133
154
|
/**
|
|
134
155
|
* Override the git-presence probe (test seam — defaults to `Bun.which`).
|
|
135
156
|
* Inject a fn returning `null` to exercise the git-not-installed path.
|
|
@@ -137,6 +158,31 @@ export interface ImportOpts {
|
|
|
137
158
|
which?: (cmd: string) => string | null;
|
|
138
159
|
}
|
|
139
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Default stall bound: ten minutes of total silence from `git clone`.
|
|
163
|
+
*
|
|
164
|
+
* Sized off what git actually does — with `--progress` it repaints the
|
|
165
|
+
* counter every few hundred milliseconds while it's moving, so ten minutes of
|
|
166
|
+
* nothing means the transfer is dead, not slow. Generous enough to cover a
|
|
167
|
+
* remote that's slow to enumerate objects on a very large repo before the
|
|
168
|
+
* first byte lands.
|
|
169
|
+
*/
|
|
170
|
+
export const DEFAULT_CLONE_STALL_TIMEOUT_MS = 10 * 60_000;
|
|
171
|
+
|
|
172
|
+
/** Coarse stage the import is in. Drives the SPA's progress copy. */
|
|
173
|
+
export type ImportStage = "cloning" | "importing" | "syncing";
|
|
174
|
+
|
|
175
|
+
/** A progress tick handed to `ImportOpts.onProgress`. */
|
|
176
|
+
export interface ImportProgress {
|
|
177
|
+
stage: ImportStage;
|
|
178
|
+
/**
|
|
179
|
+
* Human-readable detail for the current stage — for `cloning` this is the
|
|
180
|
+
* most recent `git --progress` line ("Receiving objects: 47% …").
|
|
181
|
+
* Absent when the stage has no finer detail to report.
|
|
182
|
+
*/
|
|
183
|
+
detail?: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
140
186
|
/**
|
|
141
187
|
* Counts + warnings returned to the HTTP caller. `notes_imported` totals
|
|
142
188
|
* created+updated so the operator sees a single "imported N notes" number
|
|
@@ -185,13 +231,38 @@ export interface ImportResult {
|
|
|
185
231
|
*/
|
|
186
232
|
export type GitSpawn = (
|
|
187
233
|
argv: string[],
|
|
188
|
-
options:
|
|
234
|
+
options: GitSpawnOptions,
|
|
189
235
|
) => Promise<GitSpawnResult>;
|
|
190
236
|
|
|
237
|
+
export interface GitSpawnOptions {
|
|
238
|
+
cwd?: string;
|
|
239
|
+
/**
|
|
240
|
+
* Wall-clock cap on the whole command. `0` disables it — the default for
|
|
241
|
+
* clones (see `ImportOpts.cloneTimeoutMs`). Tests pass a small number to
|
|
242
|
+
* exercise the timeout branch.
|
|
243
|
+
*/
|
|
244
|
+
timeoutMs: number;
|
|
245
|
+
/**
|
|
246
|
+
* Cap on the gap BETWEEN progress lines. This — not `timeoutMs` — is what
|
|
247
|
+
* bounds a real clone: a 4 GB vault legitimately takes an hour, but a clone
|
|
248
|
+
* that has emitted nothing for 10 minutes is wedged (dead TCP connection,
|
|
249
|
+
* auth prompt we failed to suppress, remote hang). `0` disables it.
|
|
250
|
+
*/
|
|
251
|
+
stallTimeoutMs?: number;
|
|
252
|
+
/**
|
|
253
|
+
* Called for each line git writes to stderr, which with `--progress` is
|
|
254
|
+
* where "Receiving objects: 47% (…)" lands. Drives the job's live progress
|
|
255
|
+
* detail and resets the stall timer.
|
|
256
|
+
*/
|
|
257
|
+
onProgress?: (line: string) => void;
|
|
258
|
+
}
|
|
259
|
+
|
|
191
260
|
export interface GitSpawnResult {
|
|
192
261
|
exitCode: number;
|
|
193
262
|
stderr: string;
|
|
194
263
|
timedOut: boolean;
|
|
264
|
+
/** True when the kill came from the stall timer rather than `timeoutMs`. */
|
|
265
|
+
stalled?: boolean;
|
|
195
266
|
}
|
|
196
267
|
|
|
197
268
|
// ---------------------------------------------------------------------------
|
|
@@ -344,8 +415,27 @@ export function authedCloneUrl(
|
|
|
344
415
|
// ---------------------------------------------------------------------------
|
|
345
416
|
|
|
346
417
|
/**
|
|
347
|
-
* Run a git command with a
|
|
348
|
-
* exit code + stderr text +
|
|
418
|
+
* Run a git command with a non-interactive env, streaming stderr so the
|
|
419
|
+
* caller sees progress while it runs. Returns exit code + stderr text +
|
|
420
|
+
* timeout flags.
|
|
421
|
+
*
|
|
422
|
+
* **Why stderr is STREAMED, not buffered (vault#640).** The previous
|
|
423
|
+
* implementation awaited `proc.exited` and only then drained `proc.stderr`.
|
|
424
|
+
* That made progress structurally unobservable — the import was a black box
|
|
425
|
+
* until it finished — and it forced the caller to bound the clone with a
|
|
426
|
+
* wall-clock timeout, because a wedged clone and a slow one look identical
|
|
427
|
+
* when you can't see output. A 60s cap was the result, and it made vaults
|
|
428
|
+
* above a few thousand notes simply un-importable.
|
|
429
|
+
*
|
|
430
|
+
* Streaming lets us bound the RIGHT thing: the gap between progress lines
|
|
431
|
+
* (`stallTimeoutMs`). A big clone runs as long as it needs to as long as it's
|
|
432
|
+
* still moving; a wedged one dies in minutes. `timeoutMs` remains as an
|
|
433
|
+
* optional absolute ceiling (0 = disabled) mainly so tests can force the
|
|
434
|
+
* timeout branch deterministically.
|
|
435
|
+
*
|
|
436
|
+
* Draining stderr concurrently also fixes a latent deadlock: git blocks
|
|
437
|
+
* writing to a full stderr pipe, so a clone chatty enough to fill the pipe
|
|
438
|
+
* buffer would hang forever against the old await-then-read order.
|
|
349
439
|
*/
|
|
350
440
|
export const defaultGitSpawn: GitSpawn = async (argv, options) => {
|
|
351
441
|
let proc;
|
|
@@ -375,20 +465,74 @@ export const defaultGitSpawn: GitSpawn = async (argv, options) => {
|
|
|
375
465
|
throw err;
|
|
376
466
|
}
|
|
377
467
|
let timedOut = false;
|
|
378
|
-
|
|
379
|
-
|
|
468
|
+
let stalled = false;
|
|
469
|
+
const kill = () => {
|
|
380
470
|
try {
|
|
381
471
|
proc.kill();
|
|
382
472
|
} catch {
|
|
383
473
|
// already exited
|
|
384
474
|
}
|
|
385
|
-
}
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const absoluteTimer =
|
|
478
|
+
options.timeoutMs > 0
|
|
479
|
+
? setTimeout(() => {
|
|
480
|
+
timedOut = true;
|
|
481
|
+
kill();
|
|
482
|
+
}, options.timeoutMs)
|
|
483
|
+
: null;
|
|
484
|
+
|
|
485
|
+
// Stall timer — rearmed on every line git emits. This is the real guard.
|
|
486
|
+
let stallTimer: ReturnType<typeof setTimeout> | null = null;
|
|
487
|
+
const stallMs = options.stallTimeoutMs ?? 0;
|
|
488
|
+
const armStall = () => {
|
|
489
|
+
if (stallMs <= 0) return;
|
|
490
|
+
if (stallTimer) clearTimeout(stallTimer);
|
|
491
|
+
stallTimer = setTimeout(() => {
|
|
492
|
+
stalled = true;
|
|
493
|
+
timedOut = true;
|
|
494
|
+
kill();
|
|
495
|
+
}, stallMs);
|
|
496
|
+
};
|
|
497
|
+
armStall();
|
|
498
|
+
|
|
499
|
+
// Drain stderr line-by-line. git writes progress with `\r` (carriage
|
|
500
|
+
// return, no newline) so it can repaint one line in a terminal — split on
|
|
501
|
+
// BOTH so "Receiving objects: 47%" surfaces as it happens rather than
|
|
502
|
+
// arriving as one giant line at the end.
|
|
503
|
+
const collected: string[] = [];
|
|
504
|
+
let pending = "";
|
|
505
|
+
const emit = (line: string) => {
|
|
506
|
+
const trimmed = line.trim();
|
|
507
|
+
if (trimmed.length === 0) return;
|
|
508
|
+
collected.push(trimmed);
|
|
509
|
+
armStall();
|
|
510
|
+
try {
|
|
511
|
+
options.onProgress?.(trimmed);
|
|
512
|
+
} catch {
|
|
513
|
+
// A throwing progress callback must never take down the clone.
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
const drain = (async () => {
|
|
517
|
+
const decoder = new TextDecoder();
|
|
518
|
+
for await (const chunk of proc.stderr) {
|
|
519
|
+
pending += decoder.decode(chunk, { stream: true });
|
|
520
|
+
const parts = pending.split(/[\r\n]+/);
|
|
521
|
+
pending = parts.pop() ?? "";
|
|
522
|
+
for (const part of parts) emit(part);
|
|
523
|
+
}
|
|
524
|
+
if (pending.length > 0) emit(pending);
|
|
525
|
+
})().catch(() => {
|
|
526
|
+
// Stream errors (killed process mid-read) aren't interesting — the exit
|
|
527
|
+
// code and the timeout flags already describe what happened.
|
|
528
|
+
});
|
|
529
|
+
|
|
386
530
|
const exitCode = await proc.exited;
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
return { exitCode, stderr, timedOut };
|
|
531
|
+
await drain;
|
|
532
|
+
if (absoluteTimer) clearTimeout(absoluteTimer);
|
|
533
|
+
if (stallTimer) clearTimeout(stallTimer);
|
|
534
|
+
|
|
535
|
+
return { exitCode, stderr: collected.join("\n").trim(), timedOut, stalled };
|
|
392
536
|
};
|
|
393
537
|
|
|
394
538
|
// ---------------------------------------------------------------------------
|
|
@@ -427,7 +571,12 @@ export async function cloneAndImport(opts: ImportOpts): Promise<ImportResult> {
|
|
|
427
571
|
const spawn = opts.spawn ?? defaultGitSpawn;
|
|
428
572
|
const importer = opts.importer ?? importPortableVault;
|
|
429
573
|
const workDirRoot = opts.workDirRoot ?? tmpdir();
|
|
430
|
-
|
|
574
|
+
// 0 = no absolute ceiling; the stall bound is the real guard. See the
|
|
575
|
+
// `cloneTimeoutMs` docstring on ImportOpts for why the old 60s default was
|
|
576
|
+
// wrong rather than merely too small.
|
|
577
|
+
const cloneTimeoutMs = opts.cloneTimeoutMs ?? 0;
|
|
578
|
+
const cloneStallTimeoutMs =
|
|
579
|
+
opts.cloneStallTimeoutMs ?? DEFAULT_CLONE_STALL_TIMEOUT_MS;
|
|
431
580
|
|
|
432
581
|
const authResult = authedCloneUrl(opts.remoteUrl, opts.auth, opts.vaultName);
|
|
433
582
|
if (!authResult) {
|
|
@@ -440,15 +589,25 @@ export async function cloneAndImport(opts: ImportOpts): Promise<ImportResult> {
|
|
|
440
589
|
|
|
441
590
|
const tempDir = mkdtempSync(join(workDirRoot, "parachute-import-"));
|
|
442
591
|
try {
|
|
592
|
+
opts.onProgress?.({ stage: "cloning" });
|
|
443
593
|
const cloneResult = await spawn(
|
|
444
|
-
|
|
445
|
-
|
|
594
|
+
// `--progress` forces the progress meter even though our stderr is a
|
|
595
|
+
// pipe, not a tty. Without it git stays silent, the stall timer has
|
|
596
|
+
// nothing to observe, and the operator watches a spinner with no
|
|
597
|
+
// information for the length of a multi-GB transfer.
|
|
598
|
+
["git", "clone", "--depth", "1", "--progress", authedUrl, tempDir],
|
|
599
|
+
{
|
|
600
|
+
timeoutMs: cloneTimeoutMs,
|
|
601
|
+
stallTimeoutMs: cloneStallTimeoutMs,
|
|
602
|
+
onProgress: (line) => opts.onProgress?.({ stage: "cloning", detail: line }),
|
|
603
|
+
},
|
|
446
604
|
);
|
|
447
605
|
if (cloneResult.timedOut) {
|
|
448
|
-
|
|
449
|
-
`git clone
|
|
450
|
-
`
|
|
451
|
-
|
|
606
|
+
const why = cloneResult.stalled
|
|
607
|
+
? `git clone stalled — no progress for ${Math.floor(cloneStallTimeoutMs / 60_000)} minutes. ` +
|
|
608
|
+
`The remote stopped responding, or the credential was rejected without an error.`
|
|
609
|
+
: `git clone exceeded its ${Math.floor(cloneTimeoutMs / 1000)}s limit.`;
|
|
610
|
+
throw new CloneFailedError(`${why} URL: ${redactRemoteUrl(opts.remoteUrl)}`);
|
|
452
611
|
}
|
|
453
612
|
if (cloneResult.exitCode !== 0) {
|
|
454
613
|
// Redact any leaked URLs in stderr — git error messages echo them.
|
|
@@ -469,6 +628,7 @@ export async function cloneAndImport(opts: ImportOpts): Promise<ImportResult> {
|
|
|
469
628
|
// Delegate to the importer. `blowAway: true` for replace mode triggers
|
|
470
629
|
// the wipe-then-import path (deletes notes via the public store API
|
|
471
630
|
// so hooks fire); `false` for merge does upsert-by-id.
|
|
631
|
+
opts.onProgress?.({ stage: "importing" });
|
|
472
632
|
const stats: ImportStats = await importer(opts.store, {
|
|
473
633
|
inDir: tempDir,
|
|
474
634
|
blowAway: opts.mode === "replace",
|