@openparachute/vault 0.4.8 → 0.4.9-rc.10

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