@openparachute/vault 0.4.6 → 0.4.7-rc.2
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/portable-md.test.ts +247 -0
- package/core/src/portable-md.ts +118 -1
- package/package.json +1 -1
- package/src/cli.ts +94 -2
- package/src/config.ts +24 -0
- package/src/export-watch.test.ts +99 -0
- package/src/mirror-config.test.ts +328 -0
- package/src/mirror-config.ts +470 -0
- package/src/mirror-deps.ts +88 -0
- package/src/mirror-manager.test.ts +550 -0
- package/src/mirror-manager.ts +521 -0
- package/src/mirror-registry.ts +26 -0
- package/src/mirror-routes.test.ts +380 -0
- package/src/mirror-routes.ts +152 -0
- package/src/routing.test.ts +76 -0
- package/src/routing.ts +46 -0
- package/src/server.ts +52 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mirror lifecycle manager — boot-time bootstrap + in-process watch loop.
|
|
3
|
+
*
|
|
4
|
+
* This is the persistent counterpart to vault#346's CLI watch+commit mode.
|
|
5
|
+
* Responsibilities:
|
|
6
|
+
*
|
|
7
|
+
* - On vault server boot: read mirror config, resolve mirror path,
|
|
8
|
+
* bootstrap (mkdir + git init + initial commit) when internal + new,
|
|
9
|
+
* trigger an initial export to bring the mirror to current state,
|
|
10
|
+
* and — if `watch: true` — start an in-process polling loop.
|
|
11
|
+
*
|
|
12
|
+
* - On config change (via `PUT /admin/mirror` or operator-triggered
|
|
13
|
+
* reload): stop the current watch loop cleanly, re-resolve, restart
|
|
14
|
+
* with the new shape.
|
|
15
|
+
*
|
|
16
|
+
* - On vault server shutdown: drain in-flight export + cancel the
|
|
17
|
+
* interval timer cleanly.
|
|
18
|
+
*
|
|
19
|
+
* Singleton per-process: one `MirrorManager` instance backs the vault
|
|
20
|
+
* server's lifecycle. Tests instantiate `MirrorManager` directly with
|
|
21
|
+
* fake deps to exercise lifecycle transitions without spawning a full
|
|
22
|
+
* vault server.
|
|
23
|
+
*
|
|
24
|
+
* Phase A1 deliberately surfaces ONE mirror per vault server (matching
|
|
25
|
+
* the design doc's single-mirror-per-vault model). Multi-vault server
|
|
26
|
+
* deployments today already pin one vault per server via
|
|
27
|
+
* `PARACHUTE_VAULT_NAME` / `default_vault`; the mirror config follows
|
|
28
|
+
* suit. Multi-vault mirror routing is a future ripple (open question 2
|
|
29
|
+
* in the design doc).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { existsSync, mkdirSync, readdirSync, statSync } from "fs";
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
defaultMirrorConfig,
|
|
36
|
+
resolveMirrorPath,
|
|
37
|
+
type MirrorConfig,
|
|
38
|
+
} from "./mirror-config.ts";
|
|
39
|
+
import {
|
|
40
|
+
gitAddAll,
|
|
41
|
+
gitCommit,
|
|
42
|
+
isGitRepo,
|
|
43
|
+
runGitCommitCycle,
|
|
44
|
+
} from "./export-watch.ts";
|
|
45
|
+
import { vaultDir } from "./config.ts";
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Types
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Runtime snapshot of mirror state — surfaced via `GET /admin/mirror` so
|
|
53
|
+
* the operator (and the future hub admin SPA) can see what vault is
|
|
54
|
+
* actually doing without grepping logs.
|
|
55
|
+
*/
|
|
56
|
+
export interface MirrorStatus {
|
|
57
|
+
/** True iff `mirror.enabled` is true AND bootstrap succeeded. */
|
|
58
|
+
enabled: boolean;
|
|
59
|
+
/** True iff a watch interval timer is currently armed. */
|
|
60
|
+
watch_running: boolean;
|
|
61
|
+
/** Resolved mirror path on disk, or null if disabled / unresolved. */
|
|
62
|
+
mirror_path: string | null;
|
|
63
|
+
/** ISO timestamp of the most recent export pass (initial or watch). */
|
|
64
|
+
last_export_at: string | null;
|
|
65
|
+
/** Notes touched by the most recent export pass. */
|
|
66
|
+
last_export_notes_count: number | null;
|
|
67
|
+
/** Commit sha of the most recent vault-authored commit. Null if no commit yet. */
|
|
68
|
+
last_commit_sha: string | null;
|
|
69
|
+
/** Last error message (if any). Cleared on the next successful pass. */
|
|
70
|
+
last_error: string | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Dependency-injection seam — what the manager needs from the rest of the
|
|
75
|
+
* vault. Carrying these as fields keeps the tests cheap (no real vault DB,
|
|
76
|
+
* no real config.yaml writes) and the production wiring obvious (server.ts
|
|
77
|
+
* passes the live store + writer at construction time).
|
|
78
|
+
*/
|
|
79
|
+
export interface MirrorDeps {
|
|
80
|
+
/** Name of the vault whose state the mirror reflects. */
|
|
81
|
+
vaultName: string;
|
|
82
|
+
/**
|
|
83
|
+
* Run a single export pass into `outDir`. Returns the count of notes
|
|
84
|
+
* touched so the commit cycle can render `{{notes_changed}}`. Optional
|
|
85
|
+
* `sinceCursor` controls incremental vs full export.
|
|
86
|
+
*/
|
|
87
|
+
runExport: (opts: {
|
|
88
|
+
outDir: string;
|
|
89
|
+
sinceCursor?: string;
|
|
90
|
+
}) => Promise<{ notes: number }>;
|
|
91
|
+
/**
|
|
92
|
+
* Resolve the first-changed-note title since `cursor`, for the
|
|
93
|
+
* `{{first_note_title}}` commit-template variable. Best-effort — empty
|
|
94
|
+
* string when nothing matches.
|
|
95
|
+
*/
|
|
96
|
+
firstChangedNoteTitle: (cursor: string | undefined) => Promise<string>;
|
|
97
|
+
/** Read current mirror config from persistent storage (or defaults). */
|
|
98
|
+
readMirrorConfig: () => MirrorConfig | undefined;
|
|
99
|
+
/**
|
|
100
|
+
* Atomically persist the mirror config block. Writes the config.yaml
|
|
101
|
+
* via the standard writer — used by `PUT /admin/mirror`.
|
|
102
|
+
*/
|
|
103
|
+
writeMirrorConfig: (config: MirrorConfig) => void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Internal-mirror bootstrap
|
|
108
|
+
//
|
|
109
|
+
// `mkdir -p` → `git init` → initial commit only if the dir is empty + new.
|
|
110
|
+
// If the path already exists AND is a git repo: leave it alone (operator
|
|
111
|
+
// might have set it up themselves; we trust their state).
|
|
112
|
+
// If the path exists but ISN'T a git repo: refuse to clobber. Return an
|
|
113
|
+
// error the caller surfaces in logs + status.
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
export interface BootstrapResultOk {
|
|
117
|
+
ok: true;
|
|
118
|
+
/** True iff this call performed the initial `git init` + seed commit. */
|
|
119
|
+
initialized: boolean;
|
|
120
|
+
/** Resolved path to the mirror dir. */
|
|
121
|
+
path: string;
|
|
122
|
+
}
|
|
123
|
+
export interface BootstrapResultError {
|
|
124
|
+
ok: false;
|
|
125
|
+
error: string;
|
|
126
|
+
}
|
|
127
|
+
export type BootstrapResult = BootstrapResultOk | BootstrapResultError;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Ensure the internal mirror directory exists and is a git repo. Idempotent
|
|
131
|
+
* — calling on an already-initialized mirror is a fast no-op that just
|
|
132
|
+
* checks the existing repo.
|
|
133
|
+
*
|
|
134
|
+
* Refuse-to-clobber policy: if the path exists and contains files but
|
|
135
|
+
* isn't a git repo, we don't `git init` over the operator's data. Surface
|
|
136
|
+
* a clear error and let them choose (rm + retry, or switch to external).
|
|
137
|
+
* This matches the "don't auto-git-init" framing in the design doc — for
|
|
138
|
+
* internal mirrors we DO auto-init, but only on the empty case, not on
|
|
139
|
+
* pre-existing non-git state.
|
|
140
|
+
*/
|
|
141
|
+
export async function bootstrapInternalMirror(
|
|
142
|
+
path: string,
|
|
143
|
+
): Promise<BootstrapResult> {
|
|
144
|
+
if (existsSync(path)) {
|
|
145
|
+
let stat;
|
|
146
|
+
try {
|
|
147
|
+
stat = statSync(path);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
error: `Could not stat internal mirror path ${path}: ${(err as Error).message ?? err}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (!stat.isDirectory()) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
error: `Internal mirror path ${path} exists but isn't a directory. Remove it (or switch to location=external) and retry.`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
// Existing dir — check git-repo-ness.
|
|
161
|
+
const isRepo = await isGitRepo(path);
|
|
162
|
+
if (isRepo) return { ok: true, initialized: false, path };
|
|
163
|
+
// Non-empty, non-git: refuse to clobber.
|
|
164
|
+
const entries = readdirSync(path);
|
|
165
|
+
if (entries.length > 0) {
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
error: `Internal mirror path ${path} exists with ${entries.length} entries but isn't a git repository. Remove it (\`rm -rf ${path}\`) and restart the vault to re-bootstrap, or switch to a different location.`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
// Empty dir, not yet a repo — fall through to init.
|
|
172
|
+
} else {
|
|
173
|
+
mkdirSync(path, { recursive: true });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// `git init` — default branch `main` to match the CLI's convention from
|
|
177
|
+
// vault#346 and avoid the `git init` legacy `master` default surprising
|
|
178
|
+
// operators in newer git installs.
|
|
179
|
+
const initProc = Bun.spawn(["git", "init", "-q", "-b", "main"], { cwd: path, stdout: "pipe", stderr: "pipe" });
|
|
180
|
+
const initCode = await initProc.exited;
|
|
181
|
+
if (initCode !== 0) {
|
|
182
|
+
const stderr = new TextDecoder().decode(await new Response(initProc.stderr).arrayBuffer());
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
error: `\`git init\` failed in ${path}: ${stderr.trim()}`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Seed user.email/user.name LOCAL to this repo. The internal mirror is
|
|
190
|
+
// vault-managed — operators' global git config might be unset (CI, fresh
|
|
191
|
+
// Docker containers) or might point at a different identity. Local config
|
|
192
|
+
// beats inheriting whatever happens to be on the box.
|
|
193
|
+
Bun.spawnSync(["git", "config", "user.email", "vault@parachute.computer"], { cwd: path });
|
|
194
|
+
Bun.spawnSync(["git", "config", "user.name", "Parachute Vault"], { cwd: path });
|
|
195
|
+
// Avoid GPG signing failing on dev boxes that have commit.gpgsign=true
|
|
196
|
+
// globally but no key in this context.
|
|
197
|
+
Bun.spawnSync(["git", "config", "commit.gpgsign", "false"], { cwd: path });
|
|
198
|
+
|
|
199
|
+
// Seed commit so the repo has a HEAD — keeps subsequent commits + diff
|
|
200
|
+
// tooling simple. A bare `.gitkeep` is the lightest touch.
|
|
201
|
+
const fs = await import("fs");
|
|
202
|
+
fs.writeFileSync(`${path}/.gitkeep`, "");
|
|
203
|
+
const add = await gitAddAll(path);
|
|
204
|
+
if (!add.ok) {
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
error: `\`git add\` of seed file failed in ${path}: ${add.stderr}`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const commit = await gitCommit(path, "initial mirror bootstrap");
|
|
211
|
+
if (!commit.ok) {
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
error: `\`git commit\` of seed file failed in ${path}: ${commit.stderr}`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return { ok: true, initialized: true, path };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Manager
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Singleton lifecycle controller. Holds the active mirror config, the
|
|
226
|
+
* resolved path, the watch timer (when running), and the rolling status.
|
|
227
|
+
*
|
|
228
|
+
* State transitions:
|
|
229
|
+
*
|
|
230
|
+
* constructed → start() → [enabled? bootstrap+initial-export+watch?]
|
|
231
|
+
* ↓ ↓
|
|
232
|
+
* stop() reload() — stop current loop, re-evaluate
|
|
233
|
+
*
|
|
234
|
+
* Re-entrancy: the watch tick uses a `inFlight` guard like the CLI mode so
|
|
235
|
+
* back-to-back ticks (e.g. when an export takes longer than the interval)
|
|
236
|
+
* don't pile up.
|
|
237
|
+
*/
|
|
238
|
+
export class MirrorManager {
|
|
239
|
+
private deps: MirrorDeps;
|
|
240
|
+
private status: MirrorStatus = {
|
|
241
|
+
enabled: false,
|
|
242
|
+
watch_running: false,
|
|
243
|
+
mirror_path: null,
|
|
244
|
+
last_export_at: null,
|
|
245
|
+
last_export_notes_count: null,
|
|
246
|
+
last_commit_sha: null,
|
|
247
|
+
last_error: null,
|
|
248
|
+
};
|
|
249
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
250
|
+
private stopping = false;
|
|
251
|
+
private inFlight = false;
|
|
252
|
+
/** Most recent export cursor — passed as `--since` to the next pass. */
|
|
253
|
+
private cursor: string | undefined = undefined;
|
|
254
|
+
private currentConfig: MirrorConfig = defaultMirrorConfig();
|
|
255
|
+
/**
|
|
256
|
+
* Counts how many times start() has been called. Used by tests to assert
|
|
257
|
+
* idempotency (a no-op restart on the same config doesn't re-bootstrap).
|
|
258
|
+
*/
|
|
259
|
+
private startCount = 0;
|
|
260
|
+
|
|
261
|
+
constructor(deps: MirrorDeps) {
|
|
262
|
+
this.deps = deps;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Read the current config snapshot. Returns a copy so callers can't
|
|
267
|
+
* accidentally mutate the manager's internal state.
|
|
268
|
+
*/
|
|
269
|
+
getConfig(): MirrorConfig {
|
|
270
|
+
return { ...this.currentConfig };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Get the current status snapshot. Returns a copy for the same reason as
|
|
275
|
+
* `getConfig`.
|
|
276
|
+
*/
|
|
277
|
+
getStatus(): MirrorStatus {
|
|
278
|
+
return { ...this.status };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Start (or restart) the mirror lifecycle from the current persisted
|
|
283
|
+
* config. Idempotent within an "enabled+running" steady state but always
|
|
284
|
+
* stops first to avoid double-armed timers.
|
|
285
|
+
*
|
|
286
|
+
* Returns the final status snapshot — useful for tests + the PUT
|
|
287
|
+
* endpoint response.
|
|
288
|
+
*/
|
|
289
|
+
async start(): Promise<MirrorStatus> {
|
|
290
|
+
this.startCount++;
|
|
291
|
+
await this.stop({ preserveStatus: true });
|
|
292
|
+
|
|
293
|
+
const config = this.deps.readMirrorConfig() ?? defaultMirrorConfig();
|
|
294
|
+
this.currentConfig = config;
|
|
295
|
+
|
|
296
|
+
if (!config.enabled) {
|
|
297
|
+
this.status = {
|
|
298
|
+
enabled: false,
|
|
299
|
+
watch_running: false,
|
|
300
|
+
mirror_path: null,
|
|
301
|
+
last_export_at: null,
|
|
302
|
+
last_export_notes_count: null,
|
|
303
|
+
last_commit_sha: null,
|
|
304
|
+
last_error: null,
|
|
305
|
+
};
|
|
306
|
+
return this.getStatus();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const vaultDataDir = vaultDir(this.deps.vaultName);
|
|
310
|
+
const path = resolveMirrorPath(vaultDataDir, config);
|
|
311
|
+
if (!path) {
|
|
312
|
+
this.status.enabled = false;
|
|
313
|
+
this.status.last_error =
|
|
314
|
+
"Mirror enabled but path could not be resolved (location=external without external_path?).";
|
|
315
|
+
console.warn(`[mirror] ${this.status.last_error}`);
|
|
316
|
+
return this.getStatus();
|
|
317
|
+
}
|
|
318
|
+
this.status.mirror_path = path;
|
|
319
|
+
|
|
320
|
+
// Internal bootstrap. External path is the operator's responsibility —
|
|
321
|
+
// they should have validated via the PUT endpoint before we hit boot.
|
|
322
|
+
// We re-check `isGitRepo` defensively here either way; a missing/non-
|
|
323
|
+
// git external path lands as a soft-error status without crashing the
|
|
324
|
+
// vault server.
|
|
325
|
+
if (config.location === "internal") {
|
|
326
|
+
const result = await bootstrapInternalMirror(path);
|
|
327
|
+
if (!result.ok) {
|
|
328
|
+
this.status.enabled = false;
|
|
329
|
+
this.status.last_error = result.error;
|
|
330
|
+
console.warn(`[mirror] bootstrap failed: ${result.error}`);
|
|
331
|
+
return this.getStatus();
|
|
332
|
+
}
|
|
333
|
+
if (result.initialized) {
|
|
334
|
+
console.log(`[mirror] initialized internal mirror at ${path}`);
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
// External — sanity-check the path is still there + is a git repo.
|
|
338
|
+
if (!existsSync(path)) {
|
|
339
|
+
this.status.enabled = false;
|
|
340
|
+
this.status.last_error = `External mirror path ${path} doesn't exist on disk.`;
|
|
341
|
+
console.warn(`[mirror] ${this.status.last_error}`);
|
|
342
|
+
return this.getStatus();
|
|
343
|
+
}
|
|
344
|
+
if (!(await isGitRepo(path))) {
|
|
345
|
+
this.status.enabled = false;
|
|
346
|
+
this.status.last_error = `External mirror path ${path} isn't a git repository.`;
|
|
347
|
+
console.warn(`[mirror] ${this.status.last_error}`);
|
|
348
|
+
return this.getStatus();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
this.status.enabled = true;
|
|
353
|
+
this.status.last_error = null;
|
|
354
|
+
|
|
355
|
+
// Initial export — full pass (no cursor) so the mirror starts
|
|
356
|
+
// byte-equivalent to current vault state, regardless of when the
|
|
357
|
+
// previous mirror was last refreshed.
|
|
358
|
+
try {
|
|
359
|
+
await this.runOneCycle({ isInitial: true });
|
|
360
|
+
} catch (err) {
|
|
361
|
+
const msg = (err as Error).message ?? String(err);
|
|
362
|
+
this.status.last_error = `initial export failed: ${msg}`;
|
|
363
|
+
console.warn(`[mirror] ${this.status.last_error}`);
|
|
364
|
+
// Don't disable the manager — operator may want to retry without
|
|
365
|
+
// restarting the server. Keep status.enabled true so the watch
|
|
366
|
+
// loop attempts again if armed; the next successful pass clears
|
|
367
|
+
// last_error.
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (config.watch) {
|
|
371
|
+
this.armWatchTimer();
|
|
372
|
+
this.status.watch_running = true;
|
|
373
|
+
console.log(
|
|
374
|
+
`[mirror] enabled (location: ${config.location}, watch: true) — initial export complete, watch loop running every ${config.interval_seconds}s`,
|
|
375
|
+
);
|
|
376
|
+
} else {
|
|
377
|
+
this.status.watch_running = false;
|
|
378
|
+
console.log(
|
|
379
|
+
`[mirror] enabled (location: ${config.location}, watch: false) — initial export complete, manual mode`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return this.getStatus();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Stop the watch loop cleanly. Awaits the in-flight cycle (if any) up to
|
|
388
|
+
* a soft timeout — don't hang shutdown forever, but give a running
|
|
389
|
+
* export a chance to finish + write a coherent commit.
|
|
390
|
+
*
|
|
391
|
+
* `preserveStatus: true` is the start()-internal path that keeps the
|
|
392
|
+
* status fields around for the about-to-restart pass; default false
|
|
393
|
+
* blanks them.
|
|
394
|
+
*/
|
|
395
|
+
async stop(opts: { preserveStatus?: boolean } = {}): Promise<void> {
|
|
396
|
+
this.stopping = true;
|
|
397
|
+
if (this.timer) {
|
|
398
|
+
clearInterval(this.timer);
|
|
399
|
+
this.timer = null;
|
|
400
|
+
}
|
|
401
|
+
// Brief settle window — match the CLI watch-loop convention.
|
|
402
|
+
const settleMs = 250;
|
|
403
|
+
const start = Date.now();
|
|
404
|
+
while (this.inFlight && Date.now() - start < settleMs) {
|
|
405
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
406
|
+
}
|
|
407
|
+
if (!opts.preserveStatus) {
|
|
408
|
+
this.status.watch_running = false;
|
|
409
|
+
}
|
|
410
|
+
this.stopping = false;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Reload: persist the new config + restart the lifecycle. The PUT
|
|
415
|
+
* `/admin/mirror` endpoint calls this.
|
|
416
|
+
*
|
|
417
|
+
* The persist step happens FIRST so a crash mid-restart still leaves
|
|
418
|
+
* the operator-intended config on disk; on the next vault boot it
|
|
419
|
+
* applies cleanly.
|
|
420
|
+
*/
|
|
421
|
+
async reload(newConfig: MirrorConfig): Promise<MirrorStatus> {
|
|
422
|
+
this.deps.writeMirrorConfig(newConfig);
|
|
423
|
+
return this.start();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Force a one-shot export cycle right now. Useful for "force re-export"
|
|
428
|
+
* buttons (future hub UI) + tests that want a deterministic cycle
|
|
429
|
+
* without waiting on the timer.
|
|
430
|
+
*/
|
|
431
|
+
async runNow(): Promise<MirrorStatus> {
|
|
432
|
+
if (!this.status.enabled) {
|
|
433
|
+
return this.getStatus();
|
|
434
|
+
}
|
|
435
|
+
await this.runOneCycle({ isInitial: false });
|
|
436
|
+
return this.getStatus();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Visible-for-test: number of `start()` calls so far.
|
|
440
|
+
_startCount(): number { return this.startCount; }
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Stage → export → commit pipeline for a single cycle. Updates status
|
|
444
|
+
* fields with the outcome. Errors logged + reflected in `last_error`
|
|
445
|
+
* but never rethrown out of the watch loop (the loop would die).
|
|
446
|
+
*/
|
|
447
|
+
private async runOneCycle(opts: { isInitial: boolean }): Promise<void> {
|
|
448
|
+
const nextCursor = new Date().toISOString();
|
|
449
|
+
const path = this.status.mirror_path!;
|
|
450
|
+
const sinceCursor = opts.isInitial ? undefined : this.cursor;
|
|
451
|
+
|
|
452
|
+
let stats: { notes: number };
|
|
453
|
+
try {
|
|
454
|
+
stats = await this.deps.runExport({ outDir: path, sinceCursor });
|
|
455
|
+
} catch (err) {
|
|
456
|
+
const msg = (err as Error).message ?? String(err);
|
|
457
|
+
this.status.last_error = `export failed: ${msg}`;
|
|
458
|
+
console.warn(`[mirror] ${this.status.last_error}`);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
this.cursor = nextCursor;
|
|
463
|
+
this.status.last_export_at = nextCursor;
|
|
464
|
+
this.status.last_export_notes_count = stats.notes;
|
|
465
|
+
this.status.last_error = null;
|
|
466
|
+
|
|
467
|
+
if (!this.currentConfig.auto_commit) {
|
|
468
|
+
// No commit, but cursor advanced — next pass picks up only new
|
|
469
|
+
// writes. Matches the "Manual Export" preset's spirit: vault still
|
|
470
|
+
// tracks state, just doesn't author commits.
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const firstNoteTitle = await this.deps.firstChangedNoteTitle(sinceCursor);
|
|
475
|
+
const commitResult = await runGitCommitCycle({
|
|
476
|
+
repoDir: path,
|
|
477
|
+
template: this.currentConfig.commit_template,
|
|
478
|
+
notesChanged: stats.notes,
|
|
479
|
+
vaultName: this.deps.vaultName,
|
|
480
|
+
firstNoteTitle,
|
|
481
|
+
push: this.currentConfig.auto_push,
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
if (commitResult.committed) {
|
|
485
|
+
// Resolve the new HEAD sha so the status displays the commit that
|
|
486
|
+
// just landed. Best-effort; if the rev-parse fails (it shouldn't
|
|
487
|
+
// immediately after a successful commit) we leave the prior sha.
|
|
488
|
+
const shaProc = Bun.spawn(["git", "rev-parse", "HEAD"], { cwd: path, stdout: "pipe", stderr: "pipe" });
|
|
489
|
+
await shaProc.exited;
|
|
490
|
+
const sha = new TextDecoder().decode(await new Response(shaProc.stdout).arrayBuffer()).trim();
|
|
491
|
+
if (sha.length > 0) this.status.last_commit_sha = sha;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Arm the watch interval. Tick is in-flight-guarded so a slow export
|
|
497
|
+
* doesn't pile up parallel runs.
|
|
498
|
+
*/
|
|
499
|
+
private armWatchTimer(): void {
|
|
500
|
+
if (this.timer) return;
|
|
501
|
+
const intervalMs = this.currentConfig.interval_seconds * 1000;
|
|
502
|
+
this.timer = setInterval(async () => {
|
|
503
|
+
if (this.stopping || this.inFlight) return;
|
|
504
|
+
this.inFlight = true;
|
|
505
|
+
try {
|
|
506
|
+
await this.runOneCycle({ isInitial: false });
|
|
507
|
+
} catch (err) {
|
|
508
|
+
// Defensive — runOneCycle already swallows export errors, but
|
|
509
|
+
// commit/git errors might bubble. Never kill the loop.
|
|
510
|
+
const msg = (err as Error).message ?? String(err);
|
|
511
|
+
this.status.last_error = `watch tick failed: ${msg}`;
|
|
512
|
+
console.warn(`[mirror] ${this.status.last_error}`);
|
|
513
|
+
} finally {
|
|
514
|
+
this.inFlight = false;
|
|
515
|
+
}
|
|
516
|
+
}, intervalMs);
|
|
517
|
+
// Don't keep the server process alive purely on the timer; vault
|
|
518
|
+
// already has the HTTP server + various intervals doing that.
|
|
519
|
+
this.timer.unref?.();
|
|
520
|
+
}
|
|
521
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-singleton holder for the active MirrorManager.
|
|
3
|
+
*
|
|
4
|
+
* Why a registry module: `server.ts` owns the lifecycle (constructs + starts
|
|
5
|
+
* on boot, drains on shutdown), but `routing.ts` needs to hand the same
|
|
6
|
+
* instance to the `/admin/mirror` HTTP handlers. A shared module with a
|
|
7
|
+
* setter + getter is the lightest seam — no top-level circular import,
|
|
8
|
+
* no globals on `process`, no DI framework.
|
|
9
|
+
*
|
|
10
|
+
* Tests instantiate their own manager + call `setMirrorManager` to inject
|
|
11
|
+
* it before exercising the route handlers.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { MirrorManager } from "./mirror-manager.ts";
|
|
15
|
+
|
|
16
|
+
let activeManager: MirrorManager | null = null;
|
|
17
|
+
|
|
18
|
+
/** Install (or replace) the process-wide mirror manager. */
|
|
19
|
+
export function setMirrorManager(manager: MirrorManager | null): void {
|
|
20
|
+
activeManager = manager;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Retrieve the current mirror manager. Null when boot hasn't wired one yet. */
|
|
24
|
+
export function getMirrorManager(): MirrorManager | null {
|
|
25
|
+
return activeManager;
|
|
26
|
+
}
|