@indigoai-us/hq-cloud 5.26.0 → 5.27.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 (69) 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 +113 -2
  51. package/dist/watcher.d.ts.map +1 -1
  52. package/dist/watcher.js +204 -25
  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 +299 -17
  68. package/test/e2e/sync/cross-tenant-isolation.test.ts +502 -0
  69. package/test/e2e/watcher-real-chokidar.test.ts +105 -0
@@ -31,7 +31,7 @@
31
31
  * @indigoai-us/hq-cloud (Path B) per project event-driven-sync-menubar US-007.
32
32
  */
33
33
 
34
- import type { PushEvent } from "./push-event.js";
34
+ import { encodePushEvent, type PushEvent } from "./push-event.js";
35
35
 
36
36
  export interface PushTransport {
37
37
  /** Open sockets, refresh tokens. Awaited before the watcher starts. */
@@ -82,3 +82,150 @@ export class NoopPushTransport implements PushTransport {
82
82
  this._connected = false;
83
83
  }
84
84
  }
85
+
86
+ // ─── HttpPushTransport ───────────────────────────────────────────────────────
87
+
88
+ /**
89
+ * Minimal fetch surface so tests can inject a mocked `fetch` without pulling
90
+ * in DOM/undici types. Matches the subset of the global `fetch` we use.
91
+ */
92
+ export type FetchLike = (
93
+ input: string,
94
+ init?: {
95
+ method?: string;
96
+ headers?: Record<string, string>;
97
+ body?: string;
98
+ signal?: AbortSignal;
99
+ },
100
+ ) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;
101
+
102
+ /**
103
+ * Async getter for the current Cognito access token. Long-running daemons MUST
104
+ * pass a getter (not a captured string) so each publish resolves the freshest
105
+ * token — mirrors {@link VaultServiceConfig.authToken} semantics in types.ts.
106
+ * A static string is also accepted for short-lived tools and tests.
107
+ */
108
+ export type AuthTokenSource = string | (() => string | Promise<string>);
109
+
110
+ export interface HttpPushTransportOptions {
111
+ /**
112
+ * Vault API base URL (e.g. `https://vault-api.example.com`). The same
113
+ * `apiUrl` the runner already resolves for VaultClient. Trailing slashes
114
+ * are stripped. Required — config/env-driven, no hard-coded default.
115
+ */
116
+ apiUrl: string;
117
+ /**
118
+ * Endpoint path the PushEvent is POSTed to. Defaults to `/sync/push`
119
+ * (matches the deployed hq-pro endpoint from US-006). Override for testing
120
+ * or future endpoint moves.
121
+ */
122
+ pushPath?: string;
123
+ /** Cognito JWT — static string OR async getter. See {@link AuthTokenSource}. */
124
+ authToken: AuthTokenSource;
125
+ /**
126
+ * Optional extra headers (e.g. client identification) merged onto every
127
+ * request. Authorization + Content-Type are always set by the transport.
128
+ */
129
+ headers?: Record<string, string>;
130
+ /**
131
+ * Per-request timeout in milliseconds. Default 10_000. On timeout the
132
+ * publish rejects — the daemon treats a rejected publish as a transient
133
+ * miss (the cadence poll still covers it) and MUST NOT crash.
134
+ */
135
+ timeoutMs?: number;
136
+ /** Injectable fetch (tests). Defaults to the global `fetch`. */
137
+ fetchImpl?: FetchLike;
138
+ }
139
+
140
+ /**
141
+ * Real client `PushTransport` that POSTs encoded PushEvents to the deployed
142
+ * `/sync/push` endpoint, authenticating with the menubar's existing Cognito
143
+ * bearer token — the same auth path VaultClient uses (Authorization: Bearer
144
+ * <accessToken>, token resolved per-request via the supplied getter so it
145
+ * self-heals across refreshes).
146
+ *
147
+ * Failure posture
148
+ * ───────────────
149
+ * `publish()` rejects on a network error, a non-2xx response, or a timeout.
150
+ * The DAEMON is responsible for not letting that rejection crash it — the
151
+ * watcher's emit path catches publish errors and logs them; the periodic
152
+ * cadence poll remains the safety net that eventually ships the change. This
153
+ * transport never swallows errors itself, so callers retain full visibility.
154
+ *
155
+ * `connected` flips true on `start()` and false on `dispose()`. It is purely
156
+ * advisory (HTTP is connectionless); the health endpoint may read it but it
157
+ * carries no delivery guarantee.
158
+ */
159
+ export class HttpPushTransport implements PushTransport {
160
+ private readonly apiUrl: string;
161
+ private readonly pushPath: string;
162
+ private readonly getAuthToken: () => Promise<string>;
163
+ private readonly extraHeaders: Record<string, string>;
164
+ private readonly timeoutMs: number;
165
+ private readonly fetchImpl: FetchLike;
166
+ private _connected = false;
167
+
168
+ constructor(opts: HttpPushTransportOptions) {
169
+ if (!opts.apiUrl || opts.apiUrl.trim() === "") {
170
+ throw new Error("HttpPushTransport: apiUrl is required");
171
+ }
172
+ this.apiUrl = opts.apiUrl.replace(/\/+$/, "");
173
+ const path = opts.pushPath ?? "/sync/push";
174
+ this.pushPath = path.startsWith("/") ? path : `/${path}`;
175
+ const tok = opts.authToken;
176
+ // Normalize string|getter into a single async getter (mirrors VaultClient).
177
+ this.getAuthToken =
178
+ typeof tok === "function" ? async () => tok() : async () => tok;
179
+ this.extraHeaders = opts.headers ?? {};
180
+ this.timeoutMs = opts.timeoutMs ?? 10_000;
181
+ // Bind so a destructured global fetch keeps its receiver.
182
+ this.fetchImpl =
183
+ opts.fetchImpl ??
184
+ ((input, init) => (globalThis.fetch as unknown as FetchLike)(input, init));
185
+ }
186
+
187
+ get connected(): boolean {
188
+ return this._connected;
189
+ }
190
+
191
+ async start(): Promise<void> {
192
+ this._connected = true;
193
+ }
194
+
195
+ async publish(event: PushEvent): Promise<void> {
196
+ // Validate-on-encode (US-007 contract) — a malformed event throws a typed
197
+ // PushEventDecodeError BEFORE we hit the network.
198
+ const body = encodePushEvent(event);
199
+ const token = await this.getAuthToken();
200
+
201
+ const controller = new AbortController();
202
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
203
+ try {
204
+ const res = await this.fetchImpl(`${this.apiUrl}${this.pushPath}`, {
205
+ method: "POST",
206
+ headers: {
207
+ ...this.extraHeaders,
208
+ Authorization: `Bearer ${token}`,
209
+ "Content-Type": "application/json",
210
+ Accept: "application/json",
211
+ },
212
+ body,
213
+ signal: controller.signal,
214
+ });
215
+ if (!res.ok) {
216
+ const text = await res.text().catch(() => "");
217
+ throw new Error(
218
+ `HttpPushTransport: POST ${this.pushPath} failed (${res.status})${
219
+ text ? `: ${text}` : ""
220
+ }`,
221
+ );
222
+ }
223
+ } finally {
224
+ clearTimeout(timer);
225
+ }
226
+ }
227
+
228
+ async dispose(): Promise<void> {
229
+ this._connected = false;
230
+ }
231
+ }
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,41 @@ 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
+
343
384
  /**
344
385
  * Build the composite emit-decision predicate. Returns true when a change to
345
386
  * `absolutePath` SHOULD wake the watcher (i.e. it survives every exclusion
@@ -405,6 +446,33 @@ export interface TreeWatcherOptions {
405
446
  * closes the chokidar watcher (releasing fds) and cancels any pending debounce
406
447
  * timer. dispose() is stop() + permanent shutdown.
407
448
  */
449
+ /**
450
+ * One settled change-burst, handed to {@link TreeWatcher} listeners. Carries
451
+ * the set of relative paths that changed during the quiet window so a listener
452
+ * (e.g. {@link PushEventEmitter}) can build one PushEvent per path. `paths` is
453
+ * absolute-path → relative-path; both are needed (relative for the wire shape,
454
+ * absolute for hashing/statting the file on disk).
455
+ */
456
+ export interface TreeChangeBatch {
457
+ /** Map of absolutePath → relativePath for every path in the settled burst. */
458
+ paths: Map<string, string>;
459
+ }
460
+
461
+ /**
462
+ * Listener invoked once per settled debounce window.
463
+ *
464
+ * Backwards compatible with the US-003 `WatcherSurface` contract: the first
465
+ * argument is the OPTIONAL changed relative path the loop routes its targeted
466
+ * push to (the first path of the burst; undefined when the window settled with
467
+ * no captured path). US-008's {@link PushEventEmitter} consumes the SECOND
468
+ * argument — the full {@link TreeChangeBatch} of every path in the burst — to
469
+ * build one PushEvent per path. Listeners are free to ignore either argument.
470
+ */
471
+ export type TreeChangeListener = (
472
+ changedRelPath?: string,
473
+ batch?: TreeChangeBatch,
474
+ ) => void;
475
+
408
476
  export class TreeWatcher {
409
477
  private readonly hqRoot: string;
410
478
  private readonly debounceMs: number;
@@ -412,7 +480,9 @@ export class TreeWatcher {
412
480
  private readonly shouldEmit: WatchPathFilter;
413
481
  private watcher: FSWatcher | null = null;
414
482
  private timer: unknown = null;
415
- private listeners = new Set<() => void>();
483
+ private listeners = new Set<TreeChangeListener>();
484
+ /** Paths accumulated for the current (in-flight) debounce window. */
485
+ private pending = new Map<string, string>();
416
486
  private disposed = false;
417
487
 
418
488
  constructor(opts: TreeWatcherOptions) {
@@ -423,8 +493,15 @@ export class TreeWatcher {
423
493
  opts.pathFilter ?? createWatchPathFilter(opts.hqRoot, opts.personalMode ?? false);
424
494
  }
425
495
 
426
- /** Register a debounced-`changed` listener. Returns an unsubscribe fn. */
427
- onChange(listener: () => void): () => void {
496
+ /**
497
+ * Register a debounced-`changed` listener. Returns an unsubscribe fn.
498
+ *
499
+ * Listeners receive a {@link TreeChangeBatch} of the paths that changed in
500
+ * the settled window. Existing US-003 callers that only need the "something
501
+ * changed" signal can ignore the argument — the contract is backwards
502
+ * compatible (a zero-arg callback still type-checks).
503
+ */
504
+ onChange(listener: TreeChangeListener): () => void {
428
505
  this.listeners.add(listener);
429
506
  return () => this.listeners.delete(listener);
430
507
  }
@@ -437,15 +514,15 @@ export class TreeWatcher {
437
514
  if (this.disposed || this.watcher) return;
438
515
 
439
516
  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
- },
517
+ // `ignored` returns true to SKIP. chokidar probes a candidate for the
518
+ // descent decision BEFORE stat()ing it (stats undefined on that call),
519
+ // so a naive `!shouldEmit(path, stats?.isDirectory())` treats the
520
+ // statless probe as a file and prunes intermediate dirs (`companies/`)
521
+ // that only survive the allowlist filter as directories starving the
522
+ // in-scope leaves below them. toChokidarIgnored keeps a statless probe
523
+ // when EITHER the file or the directory reading would; the accurate
524
+ // isDir verdict is reapplied at event time in handleEvent.
525
+ ignored: toChokidarIgnored(this.shouldEmit, this.hqRoot),
449
526
  persistent: true,
450
527
  ignoreInitial: true,
451
528
  awaitWriteFinish: {
@@ -474,6 +551,9 @@ export class TreeWatcher {
474
551
  // Defensive: chokidar's `ignored` already filtered, but a path that slips
475
552
  // through (or a synthetic test event) is re-checked here.
476
553
  if (!this.shouldEmit(absolutePath, false)) return;
554
+ const abs = path.resolve(absolutePath);
555
+ const rel = path.relative(this.hqRoot, abs).split(path.sep).join("/");
556
+ this.pending.set(abs, rel);
477
557
  this.arm();
478
558
  }
479
559
 
@@ -490,9 +570,17 @@ export class TreeWatcher {
490
570
 
491
571
  private emit(): void {
492
572
  if (this.disposed) return;
573
+ // Snapshot + clear the accumulated paths so the next window starts fresh
574
+ // even if a listener re-enters synchronously.
575
+ const batch: TreeChangeBatch = { paths: new Map(this.pending) };
576
+ this.pending.clear();
577
+ // First changed relative path of the burst — the US-003 routing argument.
578
+ // undefined when the window settled with no captured path (e.g. a synthetic
579
+ // arm() with no handleEvent).
580
+ const firstRel = [...batch.paths.values()][0];
493
581
  for (const l of this.listeners) {
494
582
  try {
495
- l();
583
+ l(firstRel, batch);
496
584
  } catch (err) {
497
585
  console.error("TreeWatcher listener error:", err);
498
586
  }
@@ -519,6 +607,7 @@ export class TreeWatcher {
519
607
  this.clock.clearTimeout(this.timer);
520
608
  this.timer = null;
521
609
  }
610
+ this.pending.clear();
522
611
  if (this.watcher) {
523
612
  void this.watcher.close();
524
613
  this.watcher = null;
@@ -532,3 +621,196 @@ export class TreeWatcher {
532
621
  this.listeners.clear();
533
622
  }
534
623
  }
624
+
625
+ // ---------------------------------------------------------------------------
626
+ // US-008 — PushEventEmitter: bridge the TreeWatcher to the Phase 2 transport.
627
+ //
628
+ // On each settled debounce window, build one PushEvent per changed path
629
+ // (sha-256 contentHash, monotonic per-device sequenceNumber, originTenantId)
630
+ // and hand it to the injected PushTransport. Feature-flag gated: when the
631
+ // originTenantId is NOT enabled the emitter is DORMANT — it subscribes to
632
+ // nothing and ships nothing, so the Phase 1 poll-only behavior is unchanged.
633
+ //
634
+ // Failure posture: a transport publish that throws (network/non-2xx/timeout)
635
+ // MUST NOT crash the daemon. The emitter catches per-event publish errors and
636
+ // routes them to `onError` (default: console.error); the existing cadence
637
+ // poll remains the safety net that eventually ships the change.
638
+ // ---------------------------------------------------------------------------
639
+
640
+ /** Computes the canonical contentHash for a file: `sha256:<64-hex>`. */
641
+ async function computeContentHash(absolutePath: string): Promise<string> {
642
+ const bytes = await readFile(absolutePath);
643
+ const hex = createHash("sha256").update(bytes).digest("hex");
644
+ return `sha256:${hex}`;
645
+ }
646
+
647
+ export interface PushEventEmitterOptions {
648
+ /** Tenant identifier stamped onto every PushEvent + checked against the flag. */
649
+ originTenantId: string;
650
+ /** Device identifier stamped onto every PushEvent. */
651
+ originDeviceId: string;
652
+ /** Transport that ships each PushEvent (US-007 NoopPushTransport / HttpPushTransport). */
653
+ transport: PushTransport;
654
+ /**
655
+ * Feature-flag seam. When `isEnabled(originTenantId)` is false the emitter is
656
+ * dormant: {@link attach} subscribes nothing and {@link emitForBatch} is a
657
+ * no-op. Defaults are NOT supplied here — the caller injects an
658
+ * EventDrivenPushFlagProvider so dormancy is explicit.
659
+ */
660
+ flagProvider: EventDrivenPushFlagProvider;
661
+ /**
662
+ * Returns the next monotonic sequence number for this device. Default: an
663
+ * internal counter starting at 0. Inject to persist across daemon restarts.
664
+ */
665
+ getSequenceNumber?: () => number;
666
+ /** Clock for eventTimestamp. Default `() => new Date()`. */
667
+ now?: () => Date;
668
+ /**
669
+ * Where publish failures + hash/stat errors go. Default `console.error`.
670
+ * Receives the offending PushEvent (when known) so callers can correlate.
671
+ */
672
+ onError?: (err: Error, ctx: { relativePath?: string }) => void;
673
+ /**
674
+ * Optional structured logger for the US-011 3-log diagnostic chain. When
675
+ * supplied, the emitter logs `event=watcher.emit` (the 1st correlated link)
676
+ * carrying the PushEvent's `sequenceNumber` — the same join key stamped by
677
+ * the server `push.receive` log and the client `fanout.receive` log. Default:
678
+ * no log (the daemon stays quiet unless wired with a logger).
679
+ */
680
+ logger?: EmitterLogger;
681
+ }
682
+
683
+ /**
684
+ * Minimal structured logger surface for {@link PushEventEmitter}. A pino
685
+ * `Logger` (from `./sync/logger.ts`) satisfies this; tests inject a fake.
686
+ */
687
+ export interface EmitterLogger {
688
+ info(obj: Record<string, unknown>, msg?: string): void;
689
+ }
690
+
691
+ /**
692
+ * Bridges {@link TreeWatcher} change batches to a {@link PushTransport} as
693
+ * typed PushEvents. Construct once per daemon, then {@link attach} to a
694
+ * running TreeWatcher (returns an unsubscribe fn). Flag-gated + failure-safe.
695
+ */
696
+ export class PushEventEmitter {
697
+ private readonly originTenantId: string;
698
+ private readonly originDeviceId: string;
699
+ private readonly transport: PushTransport;
700
+ private readonly flagProvider: EventDrivenPushFlagProvider;
701
+ private readonly now: () => Date;
702
+ private readonly onError: (err: Error, ctx: { relativePath?: string }) => void;
703
+ private readonly logger: EmitterLogger | undefined;
704
+ private internalSeq = 0;
705
+ private readonly nextSeq: () => number;
706
+
707
+ constructor(opts: PushEventEmitterOptions) {
708
+ this.originTenantId = opts.originTenantId;
709
+ this.originDeviceId = opts.originDeviceId;
710
+ this.transport = opts.transport;
711
+ this.flagProvider = opts.flagProvider;
712
+ this.now = opts.now ?? (() => new Date());
713
+ this.logger = opts.logger;
714
+ this.onError =
715
+ opts.onError ??
716
+ ((err, ctx) =>
717
+ console.error("PushEventEmitter error:", ctx.relativePath ?? "", err));
718
+ this.nextSeq =
719
+ opts.getSequenceNumber ??
720
+ (() => {
721
+ const n = this.internalSeq;
722
+ this.internalSeq += 1;
723
+ return n;
724
+ });
725
+ }
726
+
727
+ /** True iff event-driven push is enabled for this emitter's tenant. */
728
+ get enabled(): boolean {
729
+ return this.flagProvider.isEnabled(this.originTenantId);
730
+ }
731
+
732
+ /**
733
+ * Subscribe to a TreeWatcher's change batches. Dormant (no subscription)
734
+ * when the flag is OFF for this tenant. Returns an unsubscribe fn (a no-op
735
+ * when dormant).
736
+ */
737
+ attach(watcher: TreeWatcher): () => void {
738
+ if (!this.enabled) return () => {};
739
+ return watcher.onChange((_changedRelPath, batch) => {
740
+ if (batch) void this.emitForBatch(batch);
741
+ });
742
+ }
743
+
744
+ /**
745
+ * Build + ship one PushEvent per changed path in the batch. No-op when the
746
+ * flag is OFF. Each path is independent: a hash/stat failure or a transport
747
+ * publish rejection for one path is caught + surfaced via `onError` and does
748
+ * NOT abort the others or propagate (the daemon must not crash; the cadence
749
+ * poll covers any miss).
750
+ */
751
+ async emitForBatch(batch: TreeChangeBatch): Promise<void> {
752
+ if (!this.enabled) return;
753
+ const entries = [...batch.paths.entries()];
754
+ await Promise.all(
755
+ entries.map(([absolutePath, relativePath]) =>
756
+ this.emitOne(absolutePath, relativePath),
757
+ ),
758
+ );
759
+ }
760
+
761
+ private async emitOne(
762
+ absolutePath: string,
763
+ relativePath: string,
764
+ ): Promise<void> {
765
+ let event: PushEvent | undefined;
766
+ try {
767
+ const [contentHash, st] = await Promise.all([
768
+ computeContentHash(absolutePath),
769
+ stat(absolutePath),
770
+ ]);
771
+ event = {
772
+ relativePath,
773
+ contentHash,
774
+ mtime: st.mtime.toISOString(),
775
+ originDeviceId: this.originDeviceId,
776
+ originTenantId: this.originTenantId,
777
+ sequenceNumber: this.nextSeq(),
778
+ eventTimestamp: this.now().toISOString(),
779
+ };
780
+ } catch (err) {
781
+ // File deleted between debounce fire and hash read (legal race), or stat
782
+ // failed. Surface, don't crash.
783
+ this.onError(err instanceof Error ? err : new Error(String(err)), {
784
+ relativePath,
785
+ });
786
+ return;
787
+ }
788
+
789
+ // US-011: 1st link of the 3-log diagnostic chain. Stamps the same
790
+ // `sequenceNumber` the server `push.receive` log and the client
791
+ // `fanout.receive` log carry, so an operator can walk one event
792
+ // end-to-end. Logged after the event is built, before the publish.
793
+ this.logger?.info(
794
+ {
795
+ event: "watcher.emit",
796
+ originTenantId: event.originTenantId,
797
+ originDeviceId: event.originDeviceId,
798
+ relativePath: event.relativePath,
799
+ contentHash: event.contentHash,
800
+ sequenceNumber: event.sequenceNumber,
801
+ eventTimestamp: event.eventTimestamp,
802
+ },
803
+ "watcher emitted push event",
804
+ );
805
+
806
+ try {
807
+ await this.transport.publish(event);
808
+ } catch (err) {
809
+ // Push failure (network / non-2xx / timeout). The cadence poll is the
810
+ // safety net — log + continue, never throw.
811
+ this.onError(err instanceof Error ? err : new Error(String(err)), {
812
+ relativePath,
813
+ });
814
+ }
815
+ }
816
+ }