@indigoai-us/hq-cloud 5.26.0 → 5.28.0

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 (70) hide show
  1. package/.github/workflows/ci.yml +34 -0
  2. package/dist/bin/sync-runner.d.ts +38 -0
  3. package/dist/bin/sync-runner.d.ts.map +1 -1
  4. package/dist/bin/sync-runner.js +75 -1
  5. package/dist/bin/sync-runner.js.map +1 -1
  6. package/dist/index.d.ts +4 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +4 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/sync/feature-flags.d.ts +136 -0
  11. package/dist/sync/feature-flags.d.ts.map +1 -0
  12. package/dist/sync/feature-flags.js +160 -0
  13. package/dist/sync/feature-flags.js.map +1 -0
  14. package/dist/sync/feature-flags.test.d.ts +24 -0
  15. package/dist/sync/feature-flags.test.d.ts.map +1 -0
  16. package/dist/sync/feature-flags.test.js +330 -0
  17. package/dist/sync/feature-flags.test.js.map +1 -0
  18. package/dist/sync/index.d.ts +10 -2
  19. package/dist/sync/index.d.ts.map +1 -1
  20. package/dist/sync/index.js +5 -1
  21. package/dist/sync/index.js.map +1 -1
  22. package/dist/sync/logger.d.ts +61 -0
  23. package/dist/sync/logger.d.ts.map +1 -0
  24. package/dist/sync/logger.js +51 -0
  25. package/dist/sync/logger.js.map +1 -0
  26. package/dist/sync/logger.test.d.ts +19 -0
  27. package/dist/sync/logger.test.d.ts.map +1 -0
  28. package/dist/sync/logger.test.js +199 -0
  29. package/dist/sync/logger.test.js.map +1 -0
  30. package/dist/sync/metrics.d.ts +89 -0
  31. package/dist/sync/metrics.d.ts.map +1 -0
  32. package/dist/sync/metrics.js +105 -0
  33. package/dist/sync/metrics.js.map +1 -0
  34. package/dist/sync/metrics.test.d.ts +19 -0
  35. package/dist/sync/metrics.test.d.ts.map +1 -0
  36. package/dist/sync/metrics.test.js +280 -0
  37. package/dist/sync/metrics.test.js.map +1 -0
  38. package/dist/sync/push-receiver.d.ts +442 -0
  39. package/dist/sync/push-receiver.d.ts.map +1 -0
  40. package/dist/sync/push-receiver.js +782 -0
  41. package/dist/sync/push-receiver.js.map +1 -0
  42. package/dist/sync/push-receiver.test.d.ts +25 -0
  43. package/dist/sync/push-receiver.test.d.ts.map +1 -0
  44. package/dist/sync/push-receiver.test.js +477 -0
  45. package/dist/sync/push-receiver.test.js.map +1 -0
  46. package/dist/sync/push-transport.d.ts +84 -1
  47. package/dist/sync/push-transport.d.ts.map +1 -1
  48. package/dist/sync/push-transport.js +84 -0
  49. package/dist/sync/push-transport.js.map +1 -1
  50. package/dist/watcher.d.ts +127 -11
  51. package/dist/watcher.d.ts.map +1 -1
  52. package/dist/watcher.js +294 -57
  53. package/dist/watcher.js.map +1 -1
  54. package/package.json +9 -5
  55. package/src/bin/sync-runner.ts +102 -1
  56. package/src/index.ts +21 -0
  57. package/src/sync/feature-flags.test.ts +392 -0
  58. package/src/sync/feature-flags.ts +229 -0
  59. package/src/sync/index.ts +57 -2
  60. package/src/sync/logger.test.ts +241 -0
  61. package/src/sync/logger.ts +79 -0
  62. package/src/sync/metrics.test.ts +380 -0
  63. package/src/sync/metrics.ts +158 -0
  64. package/src/sync/push-receiver.test.ts +545 -0
  65. package/src/sync/push-receiver.ts +1077 -0
  66. package/src/sync/push-transport.ts +148 -1
  67. package/src/watcher.ts +408 -51
  68. package/test/e2e/sync/cross-tenant-isolation.test.ts +502 -0
  69. package/test/e2e/watcher-real-chokidar.test.ts +105 -0
  70. package/test/e2e/watcher-recursive-backend.test.ts +115 -0
package/src/watcher.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  */
9
9
 
10
10
  import * as fs from "fs";
11
+ import { createHash } from "node:crypto";
12
+ import { readFile, stat } from "node:fs/promises";
11
13
  import * as path from "path";
12
14
  import { watch } from "chokidar";
13
15
  import type { FSWatcher } from "chokidar";
@@ -18,6 +20,9 @@ import { uploadFile, deleteRemoteFile } from "./s3.js";
18
20
  import type { UploadAuthor } from "./s3.js";
19
21
  import { isPersonalVaultExcluded } from "./personal-vault-exclusions.js";
20
22
  import { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL } from "./personal-vault.js";
23
+ import type { PushEvent } from "./sync/push-event.js";
24
+ import type { PushTransport } from "./sync/push-transport.js";
25
+ import type { EventDrivenPushFlagProvider } from "./sync/feature-flags.js";
21
26
 
22
27
  const DEBOUNCE_MS = 2000;
23
28
 
@@ -222,10 +227,11 @@ export class SyncWatcher {
222
227
  if (this.watcher) return;
223
228
 
224
229
  this.watcher = watch(this.hqRoot, {
225
- // Forward chokidar's stats hint so dir-only gitignore patterns
226
- // (`foo/`) match directory entries during the descent decision.
227
- ignored: (filePath: string, stats?: fs.Stats) =>
228
- !this.shouldSync(filePath, stats?.isDirectory()),
230
+ // See toChokidarIgnored: chokidar's pre-stat descent probe has no stats
231
+ // hint, so a naive file-verdict prunes intermediate allowlist dirs
232
+ // before descending to their in-scope leaves. Keep a statless probe
233
+ // when EITHER the file or directory reading would survive the filter.
234
+ ignored: toChokidarIgnored(this.shouldSync, this.hqRoot),
229
235
  persistent: true,
230
236
  ignoreInitial: true,
231
237
  awaitWriteFinish: {
@@ -340,6 +346,131 @@ export class SyncWatcher {
340
346
  /** Decision for a single path: emit a change for it, or ignore it. */
341
347
  export type WatchPathFilter = (absolutePath: string, isDir?: boolean) => boolean;
342
348
 
349
+ /**
350
+ * Translate an emit-filter into chokidar's `ignored` predicate (true = SKIP).
351
+ *
352
+ * The subtlety this encodes: chokidar probes a candidate path for the descent
353
+ * decision BEFORE it has stat()ed it, so `stats` is `undefined` on that first
354
+ * call — and if we say "ignore" there, chokidar prunes the subtree and never
355
+ * descends. In allowlist mode (`.hqinclude`) the in-scope leaves
356
+ * (`companies/*\/knowledge/`) live UNDER intermediate dirs (`companies/`,
357
+ * `companies/<slug>/`) that only survive the filter when queried as a
358
+ * directory (the ancestor matcher fires for `isDir=true` only). With no stats
359
+ * hint we don't know if the candidate is that ancestor dir or an in-scope
360
+ * file, so we must NOT prune when EITHER reading would keep it. The real
361
+ * isDir-accurate verdict is reapplied at event time (TreeWatcher.handleEvent
362
+ * re-checks with the known kind), so this only widens descent, never emit.
363
+ */
364
+ function toChokidarIgnored(
365
+ shouldEmit: WatchPathFilter,
366
+ hqRoot: string,
367
+ ): (filePath: string, stats?: fs.Stats) => boolean {
368
+ const root = path.resolve(hqRoot);
369
+ return (filePath: string, stats?: fs.Stats): boolean => {
370
+ // The watched root itself must NEVER be ignored, or chokidar tears down
371
+ // the whole watch. (shouldEmit returns false for it — it's not a valid
372
+ // emit target — so we special-case it here.)
373
+ if (path.resolve(filePath) === root) return false;
374
+ const isDir = stats?.isDirectory();
375
+ if (isDir === undefined) {
376
+ // Pre-stat descent probe: keep the path if it could be an in-scope file
377
+ // OR an ancestor directory of one.
378
+ return !(shouldEmit(filePath, false) || shouldEmit(filePath, true));
379
+ }
380
+ return !shouldEmit(filePath, isDir);
381
+ };
382
+ }
383
+
384
+ /** A started watch backend. `close()` releases its OS handle(s); idempotent. */
385
+ interface WatchBackend {
386
+ close(): void;
387
+ }
388
+
389
+ /**
390
+ * Platforms where Node's `fs.watch(dir, { recursive: true })` is natively
391
+ * supported by a SINGLE OS handle: macOS (FSEvents) and Windows
392
+ * (ReadDirectoryChangesW). On Linux, recursive `fs.watch` is NOT implemented
393
+ * (it throws `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`), so we fall back to
394
+ * chokidar there.
395
+ */
396
+ function supportsRecursiveWatch(): boolean {
397
+ return process.platform === "darwin" || process.platform === "win32";
398
+ }
399
+
400
+ /**
401
+ * Start watching `hqRoot`, calling `onEvent(absolutePath)` for every change.
402
+ *
403
+ * Backend selection (the whole point of this helper):
404
+ * - macOS / Windows → ONE recursive `fs.watch` handle for the entire tree.
405
+ * chokidar 4 dropped its `fsevents` backend, so on macOS it watches via
406
+ * kqueue, which costs ~1 open fd PER watched path — ~11k fds over a real
407
+ * HQ tree, which EMFILEs under the default soft `ulimit -n` (256) and
408
+ * silently kills the watcher (instant sync then falls back to the poll).
409
+ * A single recursive watch is 1 handle regardless of tree size.
410
+ * - Linux / other → chokidar (recursive `fs.watch` is unsupported there).
411
+ *
412
+ * The recursive backend does NO descent-time filtering (it can't — the OS
413
+ * reports the whole subtree), so EVERY change is funneled to `onEvent`, which
414
+ * re-applies the emit filter via {@link TreeWatcher.handleEvent}. Excluded
415
+ * paths (`repos/`, `node_modules/`, out-of-scope companies) are cheaply
416
+ * dropped there before they arm the debounce. This is also why the recursive
417
+ * backend is immune to the `.hqinclude` ancestor-descent pruning that the
418
+ * chokidar backend needs {@link toChokidarIgnored} to avoid.
419
+ */
420
+ function startTreeWatch(
421
+ hqRoot: string,
422
+ shouldEmit: WatchPathFilter,
423
+ onEvent: (absolutePath: string) => void,
424
+ onError: (err: unknown) => void,
425
+ ): WatchBackend {
426
+ if (supportsRecursiveWatch()) {
427
+ try {
428
+ const native = fs.watch(
429
+ hqRoot,
430
+ { recursive: true, persistent: true },
431
+ (_eventType, filename) => {
432
+ // `filename` is relative to hqRoot (or null/empty if the platform
433
+ // couldn't provide it — nothing actionable then). `String()` coerces
434
+ // both the string and (older @types/node) Buffer shapes.
435
+ if (filename == null) return;
436
+ const rel = String(filename);
437
+ if (rel === "") return;
438
+ onEvent(path.resolve(hqRoot, rel));
439
+ },
440
+ );
441
+ native.on("error", onError);
442
+ return { close: () => native.close() };
443
+ } catch (err) {
444
+ // Recursive watch unexpectedly unavailable — fall back to chokidar
445
+ // rather than leaving the daemon with no watcher at all.
446
+ onError(err);
447
+ }
448
+ }
449
+
450
+ const cw = watch(hqRoot, {
451
+ // chokidar fallback (Linux): see toChokidarIgnored for why the descent
452
+ // probe must not prune ancestor dirs of allowlisted leaves.
453
+ ignored: toChokidarIgnored(shouldEmit, hqRoot),
454
+ persistent: true,
455
+ ignoreInitial: true,
456
+ awaitWriteFinish: {
457
+ stabilityThreshold: 500,
458
+ pollInterval: 100,
459
+ },
460
+ });
461
+ cw.on("add", onEvent)
462
+ .on("change", onEvent)
463
+ .on("unlink", onEvent)
464
+ .on("addDir", onEvent)
465
+ .on("unlinkDir", onEvent)
466
+ .on("error", onError);
467
+ return {
468
+ close: () => {
469
+ void cw.close();
470
+ },
471
+ };
472
+ }
473
+
343
474
  /**
344
475
  * Build the composite emit-decision predicate. Returns true when a change to
345
476
  * `absolutePath` SHOULD wake the watcher (i.e. it survives every exclusion
@@ -405,14 +536,43 @@ export interface TreeWatcherOptions {
405
536
  * closes the chokidar watcher (releasing fds) and cancels any pending debounce
406
537
  * timer. dispose() is stop() + permanent shutdown.
407
538
  */
539
+ /**
540
+ * One settled change-burst, handed to {@link TreeWatcher} listeners. Carries
541
+ * the set of relative paths that changed during the quiet window so a listener
542
+ * (e.g. {@link PushEventEmitter}) can build one PushEvent per path. `paths` is
543
+ * absolute-path → relative-path; both are needed (relative for the wire shape,
544
+ * absolute for hashing/statting the file on disk).
545
+ */
546
+ export interface TreeChangeBatch {
547
+ /** Map of absolutePath → relativePath for every path in the settled burst. */
548
+ paths: Map<string, string>;
549
+ }
550
+
551
+ /**
552
+ * Listener invoked once per settled debounce window.
553
+ *
554
+ * Backwards compatible with the US-003 `WatcherSurface` contract: the first
555
+ * argument is the OPTIONAL changed relative path the loop routes its targeted
556
+ * push to (the first path of the burst; undefined when the window settled with
557
+ * no captured path). US-008's {@link PushEventEmitter} consumes the SECOND
558
+ * argument — the full {@link TreeChangeBatch} of every path in the burst — to
559
+ * build one PushEvent per path. Listeners are free to ignore either argument.
560
+ */
561
+ export type TreeChangeListener = (
562
+ changedRelPath?: string,
563
+ batch?: TreeChangeBatch,
564
+ ) => void;
565
+
408
566
  export class TreeWatcher {
409
567
  private readonly hqRoot: string;
410
568
  private readonly debounceMs: number;
411
569
  private readonly clock: Clock;
412
570
  private readonly shouldEmit: WatchPathFilter;
413
- private watcher: FSWatcher | null = null;
571
+ private backend: WatchBackend | null = null;
414
572
  private timer: unknown = null;
415
- private listeners = new Set<() => void>();
573
+ private listeners = new Set<TreeChangeListener>();
574
+ /** Paths accumulated for the current (in-flight) debounce window. */
575
+ private pending = new Map<string, string>();
416
576
  private disposed = false;
417
577
 
418
578
  constructor(opts: TreeWatcherOptions) {
@@ -423,57 +583,52 @@ export class TreeWatcher {
423
583
  opts.pathFilter ?? createWatchPathFilter(opts.hqRoot, opts.personalMode ?? false);
424
584
  }
425
585
 
426
- /** Register a debounced-`changed` listener. Returns an unsubscribe fn. */
427
- onChange(listener: () => void): () => void {
586
+ /**
587
+ * Register a debounced-`changed` listener. Returns an unsubscribe fn.
588
+ *
589
+ * Listeners receive a {@link TreeChangeBatch} of the paths that changed in
590
+ * the settled window. Existing US-003 callers that only need the "something
591
+ * changed" signal can ignore the argument — the contract is backwards
592
+ * compatible (a zero-arg callback still type-checks).
593
+ */
594
+ onChange(listener: TreeChangeListener): () => void {
428
595
  this.listeners.add(listener);
429
596
  return () => this.listeners.delete(listener);
430
597
  }
431
598
 
432
599
  /**
433
600
  * Begin watching. Idempotent — a second call while already running is a
434
- * no-op (no second chokidar instance, no leaked fds).
601
+ * no-op (no second watch backend, no leaked handles).
602
+ *
603
+ * Uses a SINGLE recursive `fs.watch` on macOS/Windows (1 OS handle for the
604
+ * whole tree) and falls back to chokidar on Linux. See {@link startTreeWatch}
605
+ * for why per-path watching is avoided (kqueue fd exhaustion → EMFILE).
435
606
  */
436
607
  start(): void {
437
- if (this.disposed || this.watcher) return;
438
-
439
- this.watcher = watch(this.hqRoot, {
440
- // chokidar passes a stats hint so dir-only gitignore patterns (`foo/`)
441
- // match directory entries during the descent decision. `ignored`
442
- // returns true to SKIP, so invert the emit predicate. The watched root
443
- // itself must NEVER be ignored (shouldEmit returns false for it because
444
- // it is not a valid emit target), or chokidar tears down the whole watch.
445
- ignored: (filePath: string, stats?: fs.Stats) => {
446
- if (path.resolve(filePath) === path.resolve(this.hqRoot)) return false;
447
- return !this.shouldEmit(filePath, stats?.isDirectory());
448
- },
449
- persistent: true,
450
- ignoreInitial: true,
451
- awaitWriteFinish: {
452
- stabilityThreshold: 500,
453
- pollInterval: 100,
454
- },
455
- });
456
-
457
- const onEvent = (absolutePath: string) => this.handleEvent(absolutePath);
458
- this.watcher
459
- .on("add", onEvent)
460
- .on("change", onEvent)
461
- .on("unlink", onEvent)
462
- .on("addDir", onEvent)
463
- .on("unlinkDir", onEvent)
464
- .on("error", (err) => console.error("TreeWatcher error:", err));
608
+ if (this.disposed || this.backend) return;
609
+ this.backend = startTreeWatch(
610
+ this.hqRoot,
611
+ this.shouldEmit,
612
+ (absolutePath) => this.handleEvent(absolutePath),
613
+ (err) => console.error("TreeWatcher error:", err),
614
+ );
465
615
  }
466
616
 
467
617
  /**
468
- * Test/seam entry point: feed a raw filesystem path as if chokidar reported
469
- * it. Applies the emit filter then arms the debounce. Real chokidar events
470
- * route through here too.
618
+ * Test/seam entry point: feed a raw filesystem path as if the backend
619
+ * reported it. Applies the emit filter then arms the debounce. Real watch
620
+ * events route through here too — and for the recursive backend this is the
621
+ * ONLY place filtering happens, so out-of-scope paths are dropped here.
471
622
  */
472
623
  handleEvent(absolutePath: string): void {
473
624
  if (this.disposed) return;
474
- // Defensive: chokidar's `ignored` already filtered, but a path that slips
475
- // through (or a synthetic test event) is re-checked here.
625
+ // The recursive backend does no descent-time filtering, so this is the
626
+ // authoritative emit gate; the chokidar backend's `ignored` pre-filters
627
+ // but a slipped-through (or synthetic test) path is re-checked here too.
476
628
  if (!this.shouldEmit(absolutePath, false)) return;
629
+ const abs = path.resolve(absolutePath);
630
+ const rel = path.relative(this.hqRoot, abs).split(path.sep).join("/");
631
+ this.pending.set(abs, rel);
477
632
  this.arm();
478
633
  }
479
634
 
@@ -490,18 +645,26 @@ export class TreeWatcher {
490
645
 
491
646
  private emit(): void {
492
647
  if (this.disposed) return;
648
+ // Snapshot + clear the accumulated paths so the next window starts fresh
649
+ // even if a listener re-enters synchronously.
650
+ const batch: TreeChangeBatch = { paths: new Map(this.pending) };
651
+ this.pending.clear();
652
+ // First changed relative path of the burst — the US-003 routing argument.
653
+ // undefined when the window settled with no captured path (e.g. a synthetic
654
+ // arm() with no handleEvent).
655
+ const firstRel = [...batch.paths.values()][0];
493
656
  for (const l of this.listeners) {
494
657
  try {
495
- l();
658
+ l(firstRel, batch);
496
659
  } catch (err) {
497
660
  console.error("TreeWatcher listener error:", err);
498
661
  }
499
662
  }
500
663
  }
501
664
 
502
- /** True while the chokidar watcher is active. */
665
+ /** True while the watch backend is active. */
503
666
  isWatching(): boolean {
504
- return this.watcher !== null;
667
+ return this.backend !== null;
505
668
  }
506
669
 
507
670
  /** Number of pending debounce timers — a leak check for tests. */
@@ -510,18 +673,19 @@ export class TreeWatcher {
510
673
  }
511
674
 
512
675
  /**
513
- * Stop watching: close the chokidar watcher (releasing fds) and cancel any
514
- * pending debounce timer. Idempotent. The instance can be restarted with
515
- * {@link start} unless {@link dispose} was called.
676
+ * Stop watching: close the watch backend (releasing its OS handle) and
677
+ * cancel any pending debounce timer. Idempotent. The instance can be
678
+ * restarted with {@link start} unless {@link dispose} was called.
516
679
  */
517
680
  stop(): void {
518
681
  if (this.timer !== null) {
519
682
  this.clock.clearTimeout(this.timer);
520
683
  this.timer = null;
521
684
  }
522
- if (this.watcher) {
523
- void this.watcher.close();
524
- this.watcher = null;
685
+ this.pending.clear();
686
+ if (this.backend) {
687
+ this.backend.close();
688
+ this.backend = null;
525
689
  }
526
690
  }
527
691
 
@@ -532,3 +696,196 @@ export class TreeWatcher {
532
696
  this.listeners.clear();
533
697
  }
534
698
  }
699
+
700
+ // ---------------------------------------------------------------------------
701
+ // US-008 — PushEventEmitter: bridge the TreeWatcher to the Phase 2 transport.
702
+ //
703
+ // On each settled debounce window, build one PushEvent per changed path
704
+ // (sha-256 contentHash, monotonic per-device sequenceNumber, originTenantId)
705
+ // and hand it to the injected PushTransport. Feature-flag gated: when the
706
+ // originTenantId is NOT enabled the emitter is DORMANT — it subscribes to
707
+ // nothing and ships nothing, so the Phase 1 poll-only behavior is unchanged.
708
+ //
709
+ // Failure posture: a transport publish that throws (network/non-2xx/timeout)
710
+ // MUST NOT crash the daemon. The emitter catches per-event publish errors and
711
+ // routes them to `onError` (default: console.error); the existing cadence
712
+ // poll remains the safety net that eventually ships the change.
713
+ // ---------------------------------------------------------------------------
714
+
715
+ /** Computes the canonical contentHash for a file: `sha256:<64-hex>`. */
716
+ async function computeContentHash(absolutePath: string): Promise<string> {
717
+ const bytes = await readFile(absolutePath);
718
+ const hex = createHash("sha256").update(bytes).digest("hex");
719
+ return `sha256:${hex}`;
720
+ }
721
+
722
+ export interface PushEventEmitterOptions {
723
+ /** Tenant identifier stamped onto every PushEvent + checked against the flag. */
724
+ originTenantId: string;
725
+ /** Device identifier stamped onto every PushEvent. */
726
+ originDeviceId: string;
727
+ /** Transport that ships each PushEvent (US-007 NoopPushTransport / HttpPushTransport). */
728
+ transport: PushTransport;
729
+ /**
730
+ * Feature-flag seam. When `isEnabled(originTenantId)` is false the emitter is
731
+ * dormant: {@link attach} subscribes nothing and {@link emitForBatch} is a
732
+ * no-op. Defaults are NOT supplied here — the caller injects an
733
+ * EventDrivenPushFlagProvider so dormancy is explicit.
734
+ */
735
+ flagProvider: EventDrivenPushFlagProvider;
736
+ /**
737
+ * Returns the next monotonic sequence number for this device. Default: an
738
+ * internal counter starting at 0. Inject to persist across daemon restarts.
739
+ */
740
+ getSequenceNumber?: () => number;
741
+ /** Clock for eventTimestamp. Default `() => new Date()`. */
742
+ now?: () => Date;
743
+ /**
744
+ * Where publish failures + hash/stat errors go. Default `console.error`.
745
+ * Receives the offending PushEvent (when known) so callers can correlate.
746
+ */
747
+ onError?: (err: Error, ctx: { relativePath?: string }) => void;
748
+ /**
749
+ * Optional structured logger for the US-011 3-log diagnostic chain. When
750
+ * supplied, the emitter logs `event=watcher.emit` (the 1st correlated link)
751
+ * carrying the PushEvent's `sequenceNumber` — the same join key stamped by
752
+ * the server `push.receive` log and the client `fanout.receive` log. Default:
753
+ * no log (the daemon stays quiet unless wired with a logger).
754
+ */
755
+ logger?: EmitterLogger;
756
+ }
757
+
758
+ /**
759
+ * Minimal structured logger surface for {@link PushEventEmitter}. A pino
760
+ * `Logger` (from `./sync/logger.ts`) satisfies this; tests inject a fake.
761
+ */
762
+ export interface EmitterLogger {
763
+ info(obj: Record<string, unknown>, msg?: string): void;
764
+ }
765
+
766
+ /**
767
+ * Bridges {@link TreeWatcher} change batches to a {@link PushTransport} as
768
+ * typed PushEvents. Construct once per daemon, then {@link attach} to a
769
+ * running TreeWatcher (returns an unsubscribe fn). Flag-gated + failure-safe.
770
+ */
771
+ export class PushEventEmitter {
772
+ private readonly originTenantId: string;
773
+ private readonly originDeviceId: string;
774
+ private readonly transport: PushTransport;
775
+ private readonly flagProvider: EventDrivenPushFlagProvider;
776
+ private readonly now: () => Date;
777
+ private readonly onError: (err: Error, ctx: { relativePath?: string }) => void;
778
+ private readonly logger: EmitterLogger | undefined;
779
+ private internalSeq = 0;
780
+ private readonly nextSeq: () => number;
781
+
782
+ constructor(opts: PushEventEmitterOptions) {
783
+ this.originTenantId = opts.originTenantId;
784
+ this.originDeviceId = opts.originDeviceId;
785
+ this.transport = opts.transport;
786
+ this.flagProvider = opts.flagProvider;
787
+ this.now = opts.now ?? (() => new Date());
788
+ this.logger = opts.logger;
789
+ this.onError =
790
+ opts.onError ??
791
+ ((err, ctx) =>
792
+ console.error("PushEventEmitter error:", ctx.relativePath ?? "", err));
793
+ this.nextSeq =
794
+ opts.getSequenceNumber ??
795
+ (() => {
796
+ const n = this.internalSeq;
797
+ this.internalSeq += 1;
798
+ return n;
799
+ });
800
+ }
801
+
802
+ /** True iff event-driven push is enabled for this emitter's tenant. */
803
+ get enabled(): boolean {
804
+ return this.flagProvider.isEnabled(this.originTenantId);
805
+ }
806
+
807
+ /**
808
+ * Subscribe to a TreeWatcher's change batches. Dormant (no subscription)
809
+ * when the flag is OFF for this tenant. Returns an unsubscribe fn (a no-op
810
+ * when dormant).
811
+ */
812
+ attach(watcher: TreeWatcher): () => void {
813
+ if (!this.enabled) return () => {};
814
+ return watcher.onChange((_changedRelPath, batch) => {
815
+ if (batch) void this.emitForBatch(batch);
816
+ });
817
+ }
818
+
819
+ /**
820
+ * Build + ship one PushEvent per changed path in the batch. No-op when the
821
+ * flag is OFF. Each path is independent: a hash/stat failure or a transport
822
+ * publish rejection for one path is caught + surfaced via `onError` and does
823
+ * NOT abort the others or propagate (the daemon must not crash; the cadence
824
+ * poll covers any miss).
825
+ */
826
+ async emitForBatch(batch: TreeChangeBatch): Promise<void> {
827
+ if (!this.enabled) return;
828
+ const entries = [...batch.paths.entries()];
829
+ await Promise.all(
830
+ entries.map(([absolutePath, relativePath]) =>
831
+ this.emitOne(absolutePath, relativePath),
832
+ ),
833
+ );
834
+ }
835
+
836
+ private async emitOne(
837
+ absolutePath: string,
838
+ relativePath: string,
839
+ ): Promise<void> {
840
+ let event: PushEvent | undefined;
841
+ try {
842
+ const [contentHash, st] = await Promise.all([
843
+ computeContentHash(absolutePath),
844
+ stat(absolutePath),
845
+ ]);
846
+ event = {
847
+ relativePath,
848
+ contentHash,
849
+ mtime: st.mtime.toISOString(),
850
+ originDeviceId: this.originDeviceId,
851
+ originTenantId: this.originTenantId,
852
+ sequenceNumber: this.nextSeq(),
853
+ eventTimestamp: this.now().toISOString(),
854
+ };
855
+ } catch (err) {
856
+ // File deleted between debounce fire and hash read (legal race), or stat
857
+ // failed. Surface, don't crash.
858
+ this.onError(err instanceof Error ? err : new Error(String(err)), {
859
+ relativePath,
860
+ });
861
+ return;
862
+ }
863
+
864
+ // US-011: 1st link of the 3-log diagnostic chain. Stamps the same
865
+ // `sequenceNumber` the server `push.receive` log and the client
866
+ // `fanout.receive` log carry, so an operator can walk one event
867
+ // end-to-end. Logged after the event is built, before the publish.
868
+ this.logger?.info(
869
+ {
870
+ event: "watcher.emit",
871
+ originTenantId: event.originTenantId,
872
+ originDeviceId: event.originDeviceId,
873
+ relativePath: event.relativePath,
874
+ contentHash: event.contentHash,
875
+ sequenceNumber: event.sequenceNumber,
876
+ eventTimestamp: event.eventTimestamp,
877
+ },
878
+ "watcher emitted push event",
879
+ );
880
+
881
+ try {
882
+ await this.transport.publish(event);
883
+ } catch (err) {
884
+ // Push failure (network / non-2xx / timeout). The cadence poll is the
885
+ // safety net — log + continue, never throw.
886
+ this.onError(err instanceof Error ? err : new Error(String(err)), {
887
+ relativePath,
888
+ });
889
+ }
890
+ }
891
+ }