@openparachute/vault 0.4.8 → 0.4.9-rc.11

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.
Files changed (58) hide show
  1. package/core/src/core.test.ts +4 -1
  2. package/core/src/hooks.test.ts +320 -1
  3. package/core/src/hooks.ts +243 -38
  4. package/core/src/indexed-fields.test.ts +151 -0
  5. package/core/src/indexed-fields.ts +98 -0
  6. package/core/src/mcp.ts +99 -41
  7. package/core/src/notes.ts +26 -2
  8. package/core/src/portable-md.test.ts +304 -1
  9. package/core/src/portable-md.ts +418 -2
  10. package/core/src/schema.ts +114 -2
  11. package/core/src/store.ts +185 -2
  12. package/core/src/types.ts +28 -0
  13. package/package.json +2 -2
  14. package/src/auth-hub-jwt.test.ts +147 -0
  15. package/src/auth.ts +121 -1
  16. package/src/auto-transcribe.test.ts +7 -2
  17. package/src/auto-transcribe.ts +6 -2
  18. package/src/cli.ts +131 -36
  19. package/src/config.ts +12 -4
  20. package/src/export-watch.test.ts +74 -0
  21. package/src/export-watch.ts +108 -7
  22. package/src/github-device-flow.test.ts +404 -0
  23. package/src/github-device-flow.ts +415 -0
  24. package/src/hub-jwt.test.ts +27 -2
  25. package/src/hub-jwt.ts +10 -0
  26. package/src/mcp-http.ts +48 -39
  27. package/src/mcp-install-interactive.test.ts +10 -21
  28. package/src/mcp-install-interactive.ts +12 -21
  29. package/src/mcp-install.test.ts +141 -30
  30. package/src/mcp-install.ts +109 -3
  31. package/src/mcp-tools.ts +460 -3
  32. package/src/mirror-config.test.ts +277 -14
  33. package/src/mirror-config.ts +482 -31
  34. package/src/mirror-credentials.test.ts +601 -0
  35. package/src/mirror-credentials.ts +700 -0
  36. package/src/mirror-deps.ts +67 -17
  37. package/src/mirror-import.test.ts +550 -0
  38. package/src/mirror-import.ts +487 -0
  39. package/src/mirror-manager.test.ts +423 -12
  40. package/src/mirror-manager.ts +621 -72
  41. package/src/mirror-per-vault.test.ts +519 -0
  42. package/src/mirror-registry.ts +91 -14
  43. package/src/mirror-routes.test.ts +966 -10
  44. package/src/mirror-routes.ts +1111 -7
  45. package/src/module-config.ts +11 -5
  46. package/src/routes.ts +38 -1
  47. package/src/routing.test.ts +92 -1
  48. package/src/routing.ts +193 -20
  49. package/src/server.ts +116 -35
  50. package/src/storage.test.ts +132 -7
  51. package/src/token-store.ts +300 -5
  52. package/src/transcription-worker.ts +9 -4
  53. package/src/triggers.ts +16 -3
  54. package/src/vault.test.ts +681 -2
  55. package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
  56. package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
  57. package/web/ui/dist/index.html +2 -2
  58. package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
@@ -1,37 +1,63 @@
1
1
  /**
2
- * Mirror lifecycle manager — boot-time bootstrap + in-process watch loop.
2
+ * Mirror lifecycle manager — boot-time bootstrap + event-driven exports.
3
3
  *
4
4
  * This is the persistent counterpart to vault#346's CLI watch+commit mode.
5
+ * Post-event-driven shift (vault#382, this PR), the manager replaces the
6
+ * original `setInterval` polling loop with in-process hook subscriptions.
7
+ * Every note / tag / attachment mutation fires an event; the manager
8
+ * debounces them (~500ms) into a single export pass. A background
9
+ * safety-net poll runs once per `safety_net_seconds` (default 1h) to
10
+ * catch anything the event path missed — direct SQL writes, dropped
11
+ * hook dispatches, restart gaps.
12
+ *
5
13
  * Responsibilities:
6
14
  *
7
15
  * - On vault server boot: read mirror config, resolve mirror path,
8
16
  * bootstrap (mkdir + git init + initial commit) when internal + new,
9
17
  * trigger an initial export to bring the mirror to current state,
10
- * and — if `watch: true` — start an in-process polling loop.
18
+ * and — if `sync_mode: events` — subscribe to hooks + arm the
19
+ * safety-net poll.
11
20
  *
12
21
  * - 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.
22
+ * reload): tear down all subscriptions + timers, re-resolve,
23
+ * restart with the new shape.
24
+ *
25
+ * - On vault server shutdown: unsubscribe, cancel any pending
26
+ * debounce timer, cancel safety-net poll, let an in-flight export
27
+ * finish via the soft settle window.
15
28
  *
16
- * - On vault server shutdown: drain in-flight export + cancel the
17
- * interval timer cleanly.
29
+ * One `MirrorManager` instance PER VAULT (vault#400). The per-vault
30
+ * registry (`mirror-registry.ts`) holds them keyed by vault name; boot
31
+ * stands up a manager for each vault whose config is enabled, and the
32
+ * routes resolve a manager by the URL's vault name. Tests instantiate
33
+ * `MirrorManager` directly with fake deps to exercise lifecycle
34
+ * transitions without spawning a full vault server.
18
35
  *
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.
36
+ * Each manager is bound to its vault via `deps.vaultName` its config
37
+ * read/write, credential reads, and resolved mirror path are all
38
+ * scoped to that one vault, so vault A's mirror never touches vault B's.
23
39
  *
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).
40
+ * ## Race-condition contract
41
+ *
42
+ * - **Burst collapse** two events within DEBOUNCE_MS produce one
43
+ * export pass. The first event arms the timer; subsequent events
44
+ * re-arm it.
45
+ * - **Event during in-flight export** flag `dirtyDuringFlush`; after
46
+ * the in-flight pass completes, run another. No second concurrent
47
+ * pass; no missed events.
48
+ * - **Stop() during in-flight** — like the legacy soft-settle window,
49
+ * give the export ~250ms to finish before returning. Subscriptions
50
+ * come down BEFORE the settle wait so no further events queue.
51
+ * - **Safety net during event-driven path** — the poll's tick first
52
+ * checks the dirty bit. If clear, it runs a full sweep anyway (catches
53
+ * missed events). If set, it skips — the debounce timer's already
54
+ * coming.
30
55
  */
31
56
 
32
57
  import { existsSync, mkdirSync, readdirSync, statSync } from "fs";
33
58
 
34
59
  import {
60
+ DEFAULT_SAFETY_NET_SECONDS,
35
61
  defaultMirrorConfig,
36
62
  resolveMirrorPath,
37
63
  type MirrorConfig,
@@ -39,10 +65,25 @@ import {
39
65
  import {
40
66
  gitAddAll,
41
67
  gitCommit,
68
+ gitPush,
42
69
  isGitRepo,
70
+ redactToken,
43
71
  runGitCommitCycle,
44
72
  } from "./export-watch.ts";
45
73
  import { vaultDir } from "./config.ts";
74
+ import {
75
+ applyToGitRemote,
76
+ readCredentials,
77
+ } from "./mirror-credentials.ts";
78
+ import type { HookRegistry } from "../core/src/hooks.ts";
79
+
80
+ /**
81
+ * Debounce window between an event arrival and the export pass it
82
+ * triggers. 500ms is the sweet spot: long enough to collapse a burst
83
+ * (typing in a frontend, a batch import) into one export, short enough
84
+ * that the mirror feels live during normal editing.
85
+ */
86
+ const DEBOUNCE_MS = 500;
46
87
 
47
88
  // ---------------------------------------------------------------------------
48
89
  // Types
@@ -56,11 +97,18 @@ import { vaultDir } from "./config.ts";
56
97
  export interface MirrorStatus {
57
98
  /** True iff `mirror.enabled` is true AND bootstrap succeeded. */
58
99
  enabled: boolean;
59
- /** True iff a watch interval timer is currently armed. */
100
+ /**
101
+ * True iff hook subscriptions are currently active.
102
+ *
103
+ * Pre-event-driven this was named after the polling loop; we retain
104
+ * the name for SPA-shape stability — the SPA's "Watch loop" label
105
+ * now reflects "events subscribed + safety-net armed". The semantics
106
+ * shifted, not the API.
107
+ */
60
108
  watch_running: boolean;
61
109
  /** Resolved mirror path on disk, or null if disabled / unresolved. */
62
110
  mirror_path: string | null;
63
- /** ISO timestamp of the most recent export pass (initial or watch). */
111
+ /** ISO timestamp of the most recent export pass (initial or event-driven or safety-net). */
64
112
  last_export_at: string | null;
65
113
  /** Notes touched by the most recent export pass. */
66
114
  last_export_notes_count: number | null;
@@ -68,6 +116,28 @@ export interface MirrorStatus {
68
116
  last_commit_sha: string | null;
69
117
  /** Last error message (if any). Cleared on the next successful pass. */
70
118
  last_error: string | null;
119
+ /**
120
+ * Cut 5: push observability — surfaced via GET /admin/mirror so the
121
+ * operator (and the SPA's Status card) can see whether pushes are
122
+ * actually landing without grepping `[git-commit]` log lines.
123
+ *
124
+ * - `last_push_at` — ISO timestamp of the most recent SUCCESSFUL push.
125
+ * Null until the first successful push.
126
+ * - `last_push_sha` — sha that landed on the remote at `last_push_at`.
127
+ * Null when last_push_at is null.
128
+ * - `last_push_error` — message from the most recent FAILED push.
129
+ * Cleared (back to null) on the next successful push. Tokens are
130
+ * redacted before this field is set (push paths use the
131
+ * `gho_`/`ghp_`/userinfo regex to scrub).
132
+ * - `commits_unpushed` — count of local commits ahead of upstream
133
+ * (`git rev-list --count @{u}..HEAD`). Null when no upstream tracking
134
+ * exists yet (first push hasn't fired). 0 when the mirror is fully
135
+ * synced; positive when commits are queued.
136
+ */
137
+ last_push_at: string | null;
138
+ last_push_sha: string | null;
139
+ last_push_error: string | null;
140
+ commits_unpushed: number | null;
71
141
  }
72
142
 
73
143
  /**
@@ -88,6 +158,18 @@ export interface MirrorDeps {
88
158
  outDir: string;
89
159
  sinceCursor?: string;
90
160
  }) => Promise<{ notes: number }>;
161
+ /**
162
+ * Run an orphan-prune sweep against the mirror dir. Returns counts so
163
+ * the manager can log + surface them in status. Optional — when
164
+ * undefined, the manager skips the prune step (test seam; production
165
+ * always wires it through `mirror-deps.ts`).
166
+ */
167
+ runPrune?: (opts: { outDir: string }) => Promise<{
168
+ notes_removed: number;
169
+ sidecars_removed: number;
170
+ schemas_removed: number;
171
+ attachment_dirs_removed: number;
172
+ }>;
91
173
  /**
92
174
  * Resolve the first-changed-note title since `cursor`, for the
93
175
  * `{{first_note_title}}` commit-template variable. Best-effort — empty
@@ -101,6 +183,14 @@ export interface MirrorDeps {
101
183
  * via the standard writer — used by `PUT /admin/mirror`.
102
184
  */
103
185
  writeMirrorConfig: (config: MirrorConfig) => void;
186
+ /**
187
+ * Shared in-process hook registry. The manager calls `.onNote()` /
188
+ * `.onTag()` / `.onAttachment()` on this registry to drive
189
+ * event-driven exports. Optional — when undefined the manager runs
190
+ * with only the safety-net poll (back-compat shape for tests that
191
+ * predate the event-driven path).
192
+ */
193
+ hooks?: HookRegistry;
104
194
  }
105
195
 
106
196
  // ---------------------------------------------------------------------------
@@ -221,19 +311,64 @@ export async function bootstrapInternalMirror(
221
311
  // Manager
222
312
  // ---------------------------------------------------------------------------
223
313
 
314
+ /**
315
+ * Cut 5: count local commits ahead of upstream tracking. Implementation:
316
+ * `git rev-list --count @{u}..HEAD` returns N when N commits are ahead,
317
+ * 0 when synced, and exit-nonzero when no upstream tracking exists
318
+ * (the branch has never been pushed with `-u`). The non-zero case maps
319
+ * to `null` — distinct from "0 ahead" so the SPA can render "no remote
320
+ * tracking yet" vs "fully synced" differently if desired.
321
+ */
322
+ async function readCommitsUnpushed(repoDir: string): Promise<number | null> {
323
+ const proc = Bun.spawn(
324
+ ["git", "rev-list", "--count", "@{u}..HEAD"],
325
+ {
326
+ cwd: repoDir,
327
+ stdout: "pipe",
328
+ stderr: "pipe",
329
+ },
330
+ );
331
+ const exitCode = await proc.exited;
332
+ if (exitCode !== 0) return null; // no upstream configured yet
333
+ const out = new TextDecoder()
334
+ .decode(await new Response(proc.stdout).arrayBuffer())
335
+ .trim();
336
+ const n = Number(out);
337
+ return Number.isFinite(n) ? n : null;
338
+ }
339
+
340
+ /**
341
+ * Read the current `origin` URL from a git repo's config, or null when no
342
+ * origin is set. Module-local helper for the credential-application path.
343
+ */
344
+ async function readCurrentOrigin(repoDir: string): Promise<string | null> {
345
+ const proc = Bun.spawn(["git", "remote", "get-url", "origin"], {
346
+ cwd: repoDir,
347
+ stdout: "pipe",
348
+ stderr: "pipe",
349
+ });
350
+ const exitCode = await proc.exited;
351
+ if (exitCode !== 0) return null;
352
+ const url = new TextDecoder()
353
+ .decode(await new Response(proc.stdout).arrayBuffer())
354
+ .trim();
355
+ return url.length > 0 ? url : null;
356
+ }
357
+
224
358
  /**
225
359
  * Singleton lifecycle controller. Holds the active mirror config, the
226
- * resolved path, the watch timer (when running), and the rolling status.
360
+ * resolved path, hook subscriptions (when sync_mode=events), the
361
+ * safety-net poll timer, the debounce timer, and the rolling status.
227
362
  *
228
363
  * State transitions:
229
364
  *
230
- * constructed → start() → [enabled? bootstrap+initial-export+watch?]
365
+ * constructed → start() → [enabled? bootstrap + initial-export +
366
+ * subscribe-to-hooks + arm-safety-net?]
231
367
  * ↓ ↓
232
- * stop() reload() — stop current loop, re-evaluate
368
+ * stop() reload() — tear down everything, re-evaluate
233
369
  *
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.
370
+ * Re-entrancy: in-flight + dirtyDuringFlush guards collapse concurrent
371
+ * triggers into one pass + one follow-up (never two parallel exports).
237
372
  */
238
373
  export class MirrorManager {
239
374
  private deps: MirrorDeps;
@@ -245,10 +380,22 @@ export class MirrorManager {
245
380
  last_export_notes_count: null,
246
381
  last_commit_sha: null,
247
382
  last_error: null,
383
+ last_push_at: null,
384
+ last_push_sha: null,
385
+ last_push_error: null,
386
+ commits_unpushed: null,
248
387
  };
249
- private timer: ReturnType<typeof setInterval> | null = null;
388
+ /** Safety-net poll timer (interval). Null while not armed. */
389
+ private safetyNetTimer: ReturnType<typeof setInterval> | null = null;
390
+ /** Debounce timer (single-shot setTimeout). Null when no events pending. */
391
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null;
392
+ /** Unsubscribe handles returned by `hooks.on*()`. */
393
+ private unsubscribes: Array<() => void> = [];
250
394
  private stopping = false;
395
+ /** A flush is currently running. Other triggers should not start a new one. */
251
396
  private inFlight = false;
397
+ /** Set when an event/safety-net tick arrives during an in-flight flush. */
398
+ private dirtyDuringFlush = false;
252
399
  /** Most recent export cursor — passed as `--since` to the next pass. */
253
400
  private cursor: string | undefined = undefined;
254
401
  private currentConfig: MirrorConfig = defaultMirrorConfig();
@@ -262,6 +409,15 @@ export class MirrorManager {
262
409
  this.deps = deps;
263
410
  }
264
411
 
412
+ /**
413
+ * The vault this mirror manager belongs to. Credential reads/writes are
414
+ * per-vault (vault#399); route handlers thread this into the credential
415
+ * functions so they touch the right vault's `.mirror-credentials.yaml`.
416
+ */
417
+ getVaultName(): string {
418
+ return this.deps.vaultName;
419
+ }
420
+
265
421
  /**
266
422
  * Read the current config snapshot. Returns a copy so callers can't
267
423
  * accidentally mutate the manager's internal state.
@@ -270,6 +426,30 @@ export class MirrorManager {
270
426
  return { ...this.currentConfig };
271
427
  }
272
428
 
429
+ /**
430
+ * The config the operator + SPA should SEE for this vault — distinct from
431
+ * the live in-memory `getConfig()` only before the manager has started.
432
+ *
433
+ * Why this exists (vault#400): routes resolve a per-vault manager via the
434
+ * registry, which can LAZILY build a manager for a vault that has config
435
+ * on disk but no running instance yet (e.g. a non-default vault the
436
+ * operator configured, or a runtime-enabled vault between PUT and the
437
+ * reload's start). A freshly-constructed-but-never-started manager has
438
+ * `currentConfig === defaultMirrorConfig()` (disabled), which would make
439
+ * `GET /vault/<name>/.parachute/mirror` show "disabled" for a vault whose
440
+ * file says `enabled: true`. Reading the persisted config when we haven't
441
+ * started yet returns the truth for that vault. Once `start()` has run,
442
+ * `currentConfig` is authoritative (it reflects the same persisted config
443
+ * plus any bootstrap outcome).
444
+ */
445
+ getEffectiveConfig(): MirrorConfig {
446
+ if (this.startCount === 0) {
447
+ const persisted = this.deps.readMirrorConfig();
448
+ if (persisted) return { ...persisted };
449
+ }
450
+ return { ...this.currentConfig };
451
+ }
452
+
273
453
  /**
274
454
  * Get the current status snapshot. Returns a copy for the same reason as
275
455
  * `getConfig`.
@@ -302,6 +482,10 @@ export class MirrorManager {
302
482
  last_export_notes_count: null,
303
483
  last_commit_sha: null,
304
484
  last_error: null,
485
+ last_push_at: null,
486
+ last_push_sha: null,
487
+ last_push_error: null,
488
+ commits_unpushed: null,
305
489
  };
306
490
  return this.getStatus();
307
491
  }
@@ -352,31 +536,45 @@ export class MirrorManager {
352
536
  this.status.enabled = true;
353
537
  this.status.last_error = null;
354
538
 
539
+ // Apply UI-configured credentials to the mirror's git remote, if any.
540
+ // The PAT path stores the full authed URL; we set it as `origin`.
541
+ // The GitHub OAuth path only sets `origin` when the operator has
542
+ // picked a repo (handleAuthGithubSelectRepo writes it then); on a
543
+ // restart with credentials present but no repo picked yet, this is
544
+ // a no-op. Failures are non-fatal — the operator's mirror still
545
+ // exports; they get the original "push needs credentials" feedback
546
+ // when auto_push fires.
547
+ await this.applyCredentialsToRemote(path);
548
+
355
549
  // Initial export — full pass (no cursor) so the mirror starts
356
550
  // byte-equivalent to current vault state, regardless of when the
357
- // previous mirror was last refreshed.
551
+ // previous mirror was last refreshed. Also prunes orphans because
552
+ // the initial pass writes the entire vault; anything left over
553
+ // from a prior mirror run that doesn't match a current note is by
554
+ // definition orphaned.
358
555
  try {
359
- await this.runOneCycle({ isInitial: true });
556
+ await this.runOneCycle({ isInitial: true, prune: true });
360
557
  } catch (err) {
361
558
  const msg = (err as Error).message ?? String(err);
362
559
  this.status.last_error = `initial export failed: ${msg}`;
363
560
  console.warn(`[mirror] ${this.status.last_error}`);
364
561
  // 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.
562
+ // restarting the server. Keep status.enabled true so the safety-
563
+ // net loop attempts again if armed; the next successful pass
564
+ // clears last_error.
368
565
  }
369
566
 
370
- if (config.watch) {
371
- this.armWatchTimer();
567
+ if (config.sync_mode === "events") {
568
+ this.subscribeToHooks();
569
+ this.armSafetyNetTimer();
372
570
  this.status.watch_running = true;
373
571
  console.log(
374
- `[mirror] enabled (location: ${config.location}, watch: true) — initial export complete, watch loop running every ${config.interval_seconds}s`,
572
+ `[mirror] enabled (location: ${config.location}, sync_mode: events) — initial export complete, hooks subscribed, safety-net every ${config.safety_net_seconds}s`,
375
573
  );
376
574
  } else {
377
575
  this.status.watch_running = false;
378
576
  console.log(
379
- `[mirror] enabled (location: ${config.location}, watch: false) — initial export complete, manual mode`,
577
+ `[mirror] enabled (location: ${config.location}, sync_mode: manual) — initial export complete, manual mode`,
380
578
  );
381
579
  }
382
580
 
@@ -384,21 +582,43 @@ export class MirrorManager {
384
582
  }
385
583
 
386
584
  /**
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.
585
+ * Stop the mirror lifecycle cleanly:
586
+ * - Unsubscribe from hooks first so no new events queue.
587
+ * - Cancel debounce + safety-net timers.
588
+ * - Await the in-flight cycle (if any) up to a soft timeout.
390
589
  *
391
590
  * `preserveStatus: true` is the start()-internal path that keeps the
392
591
  * status fields around for the about-to-restart pass; default false
393
- * blanks them.
592
+ * blanks the `watch_running` indicator.
394
593
  */
395
594
  async stop(opts: { preserveStatus?: boolean } = {}): Promise<void> {
396
595
  this.stopping = true;
397
- if (this.timer) {
398
- clearInterval(this.timer);
399
- this.timer = null;
596
+
597
+ // Unsubscribe BEFORE waiting for in-flight — additional events
598
+ // arriving after we start the settle wait would re-arm the debounce
599
+ // timer and stretch the wait indefinitely.
600
+ for (const off of this.unsubscribes) {
601
+ try {
602
+ off();
603
+ } catch (err) {
604
+ // Unsubscribe should never throw; defensive log only.
605
+ console.warn(`[mirror] unsubscribe threw: ${(err as Error).message ?? err}`);
606
+ }
607
+ }
608
+ this.unsubscribes = [];
609
+
610
+ if (this.debounceTimer) {
611
+ clearTimeout(this.debounceTimer);
612
+ this.debounceTimer = null;
400
613
  }
401
- // Brief settle window — match the CLI watch-loop convention.
614
+ if (this.safetyNetTimer) {
615
+ clearInterval(this.safetyNetTimer);
616
+ this.safetyNetTimer = null;
617
+ }
618
+
619
+ // Brief settle window — give an in-flight export ~250ms to complete
620
+ // + write a coherent commit. Don't hang shutdown indefinitely; the
621
+ // export's status will surface a half-finished state on next start.
402
622
  const settleMs = 250;
403
623
  const start = Date.now();
404
624
  while (this.inFlight && Date.now() - start < settleMs) {
@@ -408,6 +628,9 @@ export class MirrorManager {
408
628
  this.status.watch_running = false;
409
629
  }
410
630
  this.stopping = false;
631
+ // Discard any dirty bit from events that arrived during shutdown —
632
+ // they'll be picked up on the next start()'s initial export anyway.
633
+ this.dirtyDuringFlush = false;
411
634
  }
412
635
 
413
636
  /**
@@ -425,26 +648,178 @@ export class MirrorManager {
425
648
 
426
649
  /**
427
650
  * 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.
651
+ * buttons (admin SPA) + tests that want a deterministic cycle without
652
+ * waiting on the debounce or safety-net timer.
653
+ *
654
+ * Always runs with prune=true: a manual trigger is an explicit "make
655
+ * the mirror match vault state right now" request; orphan sweeps are
656
+ * cheap on typical vaults and the operator probably wanted that
657
+ * effect anyway.
430
658
  */
431
659
  async runNow(): Promise<MirrorStatus> {
432
660
  if (!this.status.enabled) {
433
661
  return this.getStatus();
434
662
  }
435
- await this.runOneCycle({ isInitial: false });
663
+ await this.runOneCycle({ isInitial: false, prune: true });
436
664
  return this.getStatus();
437
665
  }
438
666
 
439
667
  // Visible-for-test: number of `start()` calls so far.
440
668
  _startCount(): number { return this.startCount; }
441
669
 
670
+ // ---------------------------------------------------------------------
671
+ // Event-driven path
672
+ // ---------------------------------------------------------------------
673
+
442
674
  /**
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).
675
+ * Subscribe to note / tag / attachment hooks. Each event arms the
676
+ * debounce timer (or marks the dirty bit if a flush is already
677
+ * running). Stop() will call the unsubscribe handles in `unsubscribes`.
678
+ *
679
+ * Subscribes to every mutation event the mirror cares about:
680
+ * - notes: created, updated, deleted
681
+ * - tags: upserted, deleted
682
+ * - attachments: created, deleted
446
683
  */
447
- private async runOneCycle(opts: { isInitial: boolean }): Promise<void> {
684
+ private subscribeToHooks(): void {
685
+ const hooks = this.deps.hooks;
686
+ if (!hooks) {
687
+ // No hook registry wired — fall back to safety-net only. Logged
688
+ // once at start; tests that exercise this path inspect status,
689
+ // not logs.
690
+ console.warn(
691
+ `[mirror] sync_mode: events requested but no HookRegistry wired in deps — running with safety-net poll only`,
692
+ );
693
+ return;
694
+ }
695
+
696
+ const onMutation = () => {
697
+ // Cheap pulse — flag dirty + arm the debounce.
698
+ if (this.inFlight) {
699
+ this.dirtyDuringFlush = true;
700
+ return;
701
+ }
702
+ this.armDebounce();
703
+ };
704
+
705
+ this.unsubscribes.push(
706
+ hooks.onNote({
707
+ name: "mirror-note-mutation",
708
+ event: ["created", "updated", "deleted"],
709
+ handler: onMutation,
710
+ }),
711
+ );
712
+ this.unsubscribes.push(
713
+ hooks.onTag({
714
+ name: "mirror-tag-mutation",
715
+ event: ["upserted", "deleted"],
716
+ handler: onMutation,
717
+ }),
718
+ );
719
+ this.unsubscribes.push(
720
+ hooks.onAttachment({
721
+ name: "mirror-attachment-mutation",
722
+ event: ["created", "deleted"],
723
+ handler: onMutation,
724
+ }),
725
+ );
726
+ }
727
+
728
+ /**
729
+ * Arm or re-arm the debounce timer. Subsequent calls within
730
+ * DEBOUNCE_MS reset the timer (classic debounce). When the timer
731
+ * fires, run an export pass; if events arrived during the export,
732
+ * run again.
733
+ */
734
+ private armDebounce(): void {
735
+ if (this.stopping) return;
736
+ if (this.debounceTimer) {
737
+ clearTimeout(this.debounceTimer);
738
+ }
739
+ this.debounceTimer = setTimeout(() => {
740
+ this.debounceTimer = null;
741
+ void this.runFlush({ prune: false });
742
+ }, DEBOUNCE_MS);
743
+ this.debounceTimer.unref?.();
744
+ }
745
+
746
+ /**
747
+ * Run a single flush — guards against concurrent runs, follows up
748
+ * with another pass if events arrived mid-flight. The event-driven
749
+ * fast path uses prune=false (rely on the targeted "deleted" events
750
+ * for delete propagation; the safety-net + manual-run paths sweep).
751
+ *
752
+ * In practice deletions DO need pruning even on the fast path —
753
+ * `exportVaultToDir` only writes existing notes; the file the
754
+ * deletion event refers to has to be removed somewhere. The current
755
+ * implementation runs a full prune sweep on every flush so deletes
756
+ * propagate within the debounce window. (If profiling shows this is
757
+ * expensive for very large vaults, a targeted-deletion queue could
758
+ * replace the sweep on the fast path — recorded as a possible
759
+ * future optimization in the design doc.)
760
+ */
761
+ private async runFlush(opts: { prune: boolean }): Promise<void> {
762
+ if (this.stopping) return;
763
+ if (this.inFlight) {
764
+ // Shouldn't happen given the dirty-bit guards in armDebounce +
765
+ // safetyNet; defensive only.
766
+ this.dirtyDuringFlush = true;
767
+ return;
768
+ }
769
+ this.inFlight = true;
770
+ try {
771
+ // Event-driven flushes prune-on-flush so deletions actually
772
+ // propagate. Cost: O(mirror size) per flush — fine for typical
773
+ // vaults. See doc above on the targeted-queue optimization.
774
+ await this.runOneCycle({ isInitial: false, prune: true });
775
+ } catch (err) {
776
+ const msg = (err as Error).message ?? String(err);
777
+ this.status.last_error = `event-driven flush failed: ${msg}`;
778
+ console.warn(`[mirror] ${this.status.last_error}`);
779
+ } finally {
780
+ this.inFlight = false;
781
+ }
782
+
783
+ // If events arrived during the flush, run again (one follow-up,
784
+ // not a loop — armDebounce re-engages if events keep coming).
785
+ if (this.dirtyDuringFlush && !this.stopping) {
786
+ this.dirtyDuringFlush = false;
787
+ this.armDebounce();
788
+ }
789
+ }
790
+
791
+ /**
792
+ * Arm the safety-net poll. Runs once per `safety_net_seconds`. When
793
+ * it fires:
794
+ * - If a flush is already pending (debounce armed) or in-flight,
795
+ * skip (the event path will catch it).
796
+ * - Otherwise, run a full prune+export sweep so anything missed by
797
+ * the events (direct SQL writes, dropped dispatches, restart
798
+ * gaps) lands in the mirror.
799
+ */
800
+ private armSafetyNetTimer(): void {
801
+ if (this.safetyNetTimer) return;
802
+ const intervalMs = (
803
+ this.currentConfig.safety_net_seconds || DEFAULT_SAFETY_NET_SECONDS
804
+ ) * 1000;
805
+ this.safetyNetTimer = setInterval(async () => {
806
+ if (this.stopping) return;
807
+ if (this.debounceTimer || this.inFlight) {
808
+ // Event path's already handling things; this poll is a no-op.
809
+ return;
810
+ }
811
+ await this.runFlush({ prune: true });
812
+ }, intervalMs);
813
+ this.safetyNetTimer.unref?.();
814
+ }
815
+
816
+ /**
817
+ * Stage → export → prune (optional) → commit pipeline for a single
818
+ * cycle. Updates status fields with the outcome. Errors logged +
819
+ * reflected in `last_error` but never rethrown out of the event
820
+ * dispatcher (would kill the loop).
821
+ */
822
+ private async runOneCycle(opts: { isInitial: boolean; prune: boolean }): Promise<void> {
448
823
  const nextCursor = new Date().toISOString();
449
824
  const path = this.status.mirror_path!;
450
825
  const sinceCursor = opts.isInitial ? undefined : this.cursor;
@@ -459,6 +834,34 @@ export class MirrorManager {
459
834
  return;
460
835
  }
461
836
 
837
+ // Orphan sweep — removes files in the mirror that don't correspond
838
+ // to a current vault note/tag/attachment. Counts surface in logs;
839
+ // the count of removed files is folded into the commit's
840
+ // `notes_changed` so the commit message reflects the delete too.
841
+ let prunedNotes = 0;
842
+ if (opts.prune && this.deps.runPrune) {
843
+ try {
844
+ const pruneStats = await this.deps.runPrune({ outDir: path });
845
+ prunedNotes = pruneStats.notes_removed + pruneStats.sidecars_removed;
846
+ const removedTotal =
847
+ pruneStats.notes_removed +
848
+ pruneStats.sidecars_removed +
849
+ pruneStats.schemas_removed +
850
+ pruneStats.attachment_dirs_removed;
851
+ if (removedTotal > 0) {
852
+ console.log(
853
+ `[mirror] prune: removed ${pruneStats.notes_removed} note(s), ${pruneStats.sidecars_removed} sidecar(s), ${pruneStats.schemas_removed} schema(s), ${pruneStats.attachment_dirs_removed} attachment dir(s)`,
854
+ );
855
+ }
856
+ } catch (err) {
857
+ // Prune failure is non-fatal — the export already wrote the
858
+ // current state; the next sweep retries.
859
+ console.warn(
860
+ `[mirror] prune failed (non-fatal): ${(err as Error).message ?? err}`,
861
+ );
862
+ }
863
+ }
864
+
462
865
  this.cursor = nextCursor;
463
866
  this.status.last_export_at = nextCursor;
464
867
  this.status.last_export_notes_count = stats.notes;
@@ -471,11 +874,23 @@ export class MirrorManager {
471
874
  return;
472
875
  }
473
876
 
877
+ // Commit when EITHER notes changed OR orphans were pruned. The
878
+ // commit message's `notes_changed` reflects the union — operators
879
+ // reading a "deleted 3 notes" diff in their mirror should see that
880
+ // count in the commit. (Pre-event-driven this was strictly
881
+ // notes-from-export; orphan deletes weren't tracked.)
882
+ const totalChanged = stats.notes + prunedNotes;
883
+ if (totalChanged === 0) {
884
+ // Nothing to commit; runGitCommitCycle would no-op anyway, but
885
+ // skipping early avoids the firstNoteTitle DB lookup.
886
+ return;
887
+ }
888
+
474
889
  const firstNoteTitle = await this.deps.firstChangedNoteTitle(sinceCursor);
475
890
  const commitResult = await runGitCommitCycle({
476
891
  repoDir: path,
477
892
  template: this.currentConfig.commit_template,
478
- notesChanged: stats.notes,
893
+ notesChanged: totalChanged,
479
894
  vaultName: this.deps.vaultName,
480
895
  firstNoteTitle,
481
896
  push: this.currentConfig.auto_push,
@@ -490,32 +905,166 @@ export class MirrorManager {
490
905
  const sha = new TextDecoder().decode(await new Response(shaProc.stdout).arrayBuffer()).trim();
491
906
  if (sha.length > 0) this.status.last_commit_sha = sha;
492
907
  }
908
+
909
+ // Cut 5: surface push outcome (success/failure + redacted error).
910
+ // runGitCommitCycle returns push info iff push was attempted; we
911
+ // mirror the same shape into status here.
912
+ if (commitResult.push) {
913
+ const now = new Date().toISOString();
914
+ if (commitResult.push.ok) {
915
+ this.status.last_push_at = now;
916
+ this.status.last_push_sha = this.status.last_commit_sha;
917
+ this.status.last_push_error = null;
918
+ } else if (commitResult.push.error) {
919
+ this.status.last_push_error = commitResult.push.error;
920
+ }
921
+ }
922
+
923
+ // Cut 5: track local-commits-ahead-of-upstream so the UI can render
924
+ // "<N> commits ready to push" subdued helper text.
925
+ this.status.commits_unpushed = await readCommitsUnpushed(path);
493
926
  }
494
927
 
495
928
  /**
496
- * Arm the watch interval. Tick is in-flight-guarded so a slow export
497
- * doesn't pile up parallel runs.
929
+ * Cut 6: fire a `git push` against the current mirror dir, capture
930
+ * the outcome into status, and return a struct the route handler can
931
+ * fold into its response.
932
+ *
933
+ * Distinguished from `runNow()` which exports + commits + pushes.
934
+ * `pushNow()` skips export + commit entirely and only pushes whatever
935
+ * has already been committed. Used by:
936
+ * - the credential save path (auto-fire after `auth/pat` /
937
+ * `auth/github/select-repo` lands) so the operator sees the
938
+ * push happen rather than waiting for the next write
939
+ * - the explicit POST /.parachute/mirror/push-now route the SPA's
940
+ * "Push now" button hits
941
+ *
942
+ * Idempotent against a fully-synced mirror (git push reports "nothing
943
+ * to push" with exit 0). Failure paths set `last_push_error` for the
944
+ * status surface.
498
945
  */
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
- // DefensiverunOneCycle 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;
946
+ async pushNow(): Promise<
947
+ | { fired: false; reason: "not_enabled" | "no_mirror_path" }
948
+ | { fired: true; pushed: boolean; sha?: string; error?: string }
949
+ > {
950
+ if (!this.status.enabled) return { fired: false, reason: "not_enabled" };
951
+ if (!this.status.mirror_path) return { fired: false, reason: "no_mirror_path" };
952
+ const path = this.status.mirror_path;
953
+ const pushResult = await gitPush(path);
954
+ const now = new Date().toISOString();
955
+ // Refresh commits_unpushed either way a no-op push still reflects
956
+ // the current state (0 ahead if synced, N if push failed mid-way).
957
+ this.status.commits_unpushed = await readCommitsUnpushed(path);
958
+ if (pushResult.ok) {
959
+ // Capture HEAD sha at this point so the UI can show the operator
960
+ // exactly what landed.
961
+ const shaProc = Bun.spawn(["git", "rev-parse", "HEAD"], {
962
+ cwd: path,
963
+ stdout: "pipe",
964
+ stderr: "pipe",
965
+ });
966
+ await shaProc.exited;
967
+ const sha = new TextDecoder()
968
+ .decode(await new Response(shaProc.stdout).arrayBuffer())
969
+ .trim();
970
+ this.status.last_push_at = now;
971
+ if (sha.length > 0) this.status.last_push_sha = sha;
972
+ this.status.last_push_error = null;
973
+ return { fired: true, pushed: true, sha: sha.length > 0 ? sha : undefined };
974
+ }
975
+ const redacted = redactToken(pushResult.stderr);
976
+ this.status.last_push_error = redacted;
977
+ console.warn(`[mirror] push-now failed (non-fatal): ${redacted}`);
978
+ return { fired: true, pushed: false, error: redacted };
979
+ }
980
+
981
+ /**
982
+ * Apply UI-configured credentials to the mirror dir's `origin`. Called
983
+ * on every start()/reload() so credential rotations land without
984
+ * requiring a server restart. Idempotent:
985
+ * - active_method=pat → set origin to the stored authed URL
986
+ * - active_method=github_oauth + a picked repo → set origin (the
987
+ * pick path stores the authed URL in the same flow)
988
+ * - active_method=github_oauth + no repo picked → no-op (UI is mid-
989
+ * flow)
990
+ * - no credentials at all → leave whatever the operator wired by
991
+ * hand alone (don't clobber a manually-set remote)
992
+ *
993
+ * Never throws — push failures will surface via the existing
994
+ * non-fatal-warn path in runGitCommitCycle.
995
+ */
996
+ private async applyCredentialsToRemote(repoDir: string): Promise<void> {
997
+ try {
998
+ const creds = readCredentials(this.deps.vaultName);
999
+ if (!creds || !creds.active_method) {
1000
+ // No UI-configured credentials. Leave the remote alone — the
1001
+ // operator may have set one up via `git remote add` manually.
1002
+ return;
515
1003
  }
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?.();
1004
+ if (creds.active_method === "pat" && creds.pat) {
1005
+ const result = await applyToGitRemote(repoDir, creds.pat.remote_url);
1006
+ if (!result.ok) {
1007
+ console.warn(
1008
+ `[mirror] could not apply PAT remote URL (non-fatal): ${result.error}`,
1009
+ );
1010
+ }
1011
+ return;
1012
+ }
1013
+ // github_oauth: the active token is set, but a repo may not yet be
1014
+ // picked. The select-repo route handler writes the URL; on a
1015
+ // subsequent restart we can't reconstruct the URL without the
1016
+ // owner/repo. Best-effort: if `origin` already points at a github.com
1017
+ // URL, rewrite it with the stored token in case the token rotated.
1018
+ if (creds.active_method === "github_oauth" && creds.github_oauth) {
1019
+ const current = await readCurrentOrigin(repoDir);
1020
+ if (current && current.includes("github.com")) {
1021
+ // Parse owner/repo out of the current URL.
1022
+ const match = current.match(
1023
+ /github\.com[/:]([^/]+)\/([^/.]+?)(?:\.git)?$/,
1024
+ );
1025
+ if (match) {
1026
+ const [, owner, repo] = match;
1027
+ const { githubAuthedRemoteUrl } = await import("./mirror-credentials.ts");
1028
+ const authed = githubAuthedRemoteUrl(
1029
+ creds.github_oauth.access_token,
1030
+ owner!,
1031
+ repo!,
1032
+ );
1033
+ const result = await applyToGitRemote(repoDir, authed);
1034
+ if (!result.ok) {
1035
+ console.warn(
1036
+ `[mirror] could not refresh github_oauth remote URL (non-fatal): ${result.error}`,
1037
+ );
1038
+ }
1039
+ }
1040
+ }
1041
+ }
1042
+ } catch (err) {
1043
+ console.warn(
1044
+ `[mirror] credential application failed (non-fatal): ${(err as Error).message ?? err}`,
1045
+ );
1046
+ }
1047
+ }
1048
+
1049
+ // ---------------------------------------------------------------------
1050
+ // Test seams
1051
+ // ---------------------------------------------------------------------
1052
+
1053
+ /**
1054
+ * Force the debounce timer to fire immediately (cancel + run flush).
1055
+ * Used by mirror-manager.test.ts to avoid sleeping through 500ms in
1056
+ * every event-driven test case. Not exposed via the public API.
1057
+ */
1058
+ async _flushDebounceForTest(): Promise<void> {
1059
+ if (this.debounceTimer) {
1060
+ clearTimeout(this.debounceTimer);
1061
+ this.debounceTimer = null;
1062
+ }
1063
+ await this.runFlush({ prune: false });
1064
+ }
1065
+
1066
+ /** Test seam: snapshot of the unsubscribe-handle count. */
1067
+ _subscriptionCount(): number {
1068
+ return this.unsubscribes.length;
520
1069
  }
521
1070
  }