@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.
- package/.github/workflows/ci.yml +34 -0
- package/dist/bin/sync-runner.d.ts +38 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +75 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/sync/feature-flags.d.ts +136 -0
- package/dist/sync/feature-flags.d.ts.map +1 -0
- package/dist/sync/feature-flags.js +160 -0
- package/dist/sync/feature-flags.js.map +1 -0
- package/dist/sync/feature-flags.test.d.ts +24 -0
- package/dist/sync/feature-flags.test.d.ts.map +1 -0
- package/dist/sync/feature-flags.test.js +330 -0
- package/dist/sync/feature-flags.test.js.map +1 -0
- package/dist/sync/index.d.ts +10 -2
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +5 -1
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/logger.d.ts +61 -0
- package/dist/sync/logger.d.ts.map +1 -0
- package/dist/sync/logger.js +51 -0
- package/dist/sync/logger.js.map +1 -0
- package/dist/sync/logger.test.d.ts +19 -0
- package/dist/sync/logger.test.d.ts.map +1 -0
- package/dist/sync/logger.test.js +199 -0
- package/dist/sync/logger.test.js.map +1 -0
- package/dist/sync/metrics.d.ts +89 -0
- package/dist/sync/metrics.d.ts.map +1 -0
- package/dist/sync/metrics.js +105 -0
- package/dist/sync/metrics.js.map +1 -0
- package/dist/sync/metrics.test.d.ts +19 -0
- package/dist/sync/metrics.test.d.ts.map +1 -0
- package/dist/sync/metrics.test.js +280 -0
- package/dist/sync/metrics.test.js.map +1 -0
- package/dist/sync/push-receiver.d.ts +442 -0
- package/dist/sync/push-receiver.d.ts.map +1 -0
- package/dist/sync/push-receiver.js +782 -0
- package/dist/sync/push-receiver.js.map +1 -0
- package/dist/sync/push-receiver.test.d.ts +25 -0
- package/dist/sync/push-receiver.test.d.ts.map +1 -0
- package/dist/sync/push-receiver.test.js +477 -0
- package/dist/sync/push-receiver.test.js.map +1 -0
- package/dist/sync/push-transport.d.ts +84 -1
- package/dist/sync/push-transport.d.ts.map +1 -1
- package/dist/sync/push-transport.js +84 -0
- package/dist/sync/push-transport.js.map +1 -1
- package/dist/watcher.d.ts +127 -11
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +294 -57
- package/dist/watcher.js.map +1 -1
- package/package.json +9 -5
- package/src/bin/sync-runner.ts +102 -1
- package/src/index.ts +21 -0
- package/src/sync/feature-flags.test.ts +392 -0
- package/src/sync/feature-flags.ts +229 -0
- package/src/sync/index.ts +57 -2
- package/src/sync/logger.test.ts +241 -0
- package/src/sync/logger.ts +79 -0
- package/src/sync/metrics.test.ts +380 -0
- package/src/sync/metrics.ts +158 -0
- package/src/sync/push-receiver.test.ts +545 -0
- package/src/sync/push-receiver.ts +1077 -0
- package/src/sync/push-transport.ts +148 -1
- package/src/watcher.ts +408 -51
- package/test/e2e/sync/cross-tenant-isolation.test.ts +502 -0
- package/test/e2e/watcher-real-chokidar.test.ts +105 -0
- 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
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
571
|
+
private backend: WatchBackend | null = null;
|
|
414
572
|
private timer: unknown = null;
|
|
415
|
-
private listeners = new Set<
|
|
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
|
-
/**
|
|
427
|
-
|
|
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
|
|
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.
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
469
|
-
* it. Applies the emit filter then arms the debounce. Real
|
|
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
|
-
//
|
|
475
|
-
//
|
|
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
|
|
665
|
+
/** True while the watch backend is active. */
|
|
503
666
|
isWatching(): boolean {
|
|
504
|
-
return this.
|
|
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
|
|
514
|
-
* pending debounce timer. Idempotent. The instance can be
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
this.
|
|
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
|
+
}
|