@indigoai-us/hq-cloud 6.14.25 → 6.14.27
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/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +13 -3
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/cli/sync-scope.test.js +87 -0
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +13 -7
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +39 -16
- package/dist/cli/sync.js.map +1 -1
- package/dist/watcher.d.ts +98 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +235 -43
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +199 -1
- package/dist/watcher.test.js.map +1 -1
- package/package.json +2 -2
- package/src/bin/sync-runner-watch-loop.ts +15 -5
- package/src/cli/sync-scope.test.ts +96 -0
- package/src/cli/sync.ts +81 -31
- package/src/watcher.test.ts +248 -0
- package/src/watcher.ts +322 -42
package/src/cli/sync.ts
CHANGED
|
@@ -316,8 +316,9 @@ export type SyncProgressEvent =
|
|
|
316
316
|
/**
|
|
317
317
|
* Emitted at most ONCE per PULL leg when the leg ran under a
|
|
318
318
|
* membership-scoped `syncMode` (`"shared"` or `"custom"` — never `"all"`)
|
|
319
|
-
* AND one or more remote keys were
|
|
320
|
-
* (`
|
|
319
|
+
* AND one or more remote keys were withheld BY THAT MEMBERSHIP SCOPE
|
|
320
|
+
* (`skip-out-of-scope` with `reason: "membership-scope"`). This turns the
|
|
321
|
+
* silent shared-vs-all gap into a
|
|
321
322
|
* VISIBLE, actionable surface. The reporter of feedback_d2082110 lost
|
|
322
323
|
* files across devices precisely because four memberships defaulted to
|
|
323
324
|
* `shared` rather than `all`, so not all vault content materialized on the
|
|
@@ -327,11 +328,16 @@ export type SyncProgressEvent =
|
|
|
327
328
|
* materialization is a deliberate, visible choice rather than a silent
|
|
328
329
|
* omission.
|
|
329
330
|
*
|
|
330
|
-
* `count` is the number of remote keys
|
|
331
|
-
* leg; `samplePaths` carries up to 10 company-relative keys for
|
|
332
|
-
* `syncMode` is the active scoped mode.
|
|
333
|
-
* `
|
|
334
|
-
*
|
|
331
|
+
* `count` is the number of remote keys the MEMBERSHIP SCOPE withheld on
|
|
332
|
+
* this leg; `samplePaths` carries up to 10 company-relative keys for
|
|
333
|
+
* display; `syncMode` is the active scoped mode. Push-only keys
|
|
334
|
+
* (`sessions/`, US-006) are deliberately EXCLUDED from both — they are
|
|
335
|
+
* withheld in every mode including `all`, so the lever this event names
|
|
336
|
+
* would not materialize them. `count` is therefore ≤
|
|
337
|
+
* `SyncResult.filesOutOfScope`, which keeps its both-causes meaning.
|
|
338
|
+
* Distinct from the push-side `scope-excluded` (which reports what a
|
|
339
|
+
* grantee could not PUSH). Not emitted in `all` mode or when nothing was
|
|
340
|
+
* withheld by the membership scope — no gap, no noise.
|
|
335
341
|
*/
|
|
336
342
|
type: "scope-materialization-gap";
|
|
337
343
|
count: number;
|
|
@@ -908,7 +914,7 @@ async function syncWithOperationLockHeld(
|
|
|
908
914
|
await verifyPlannedJournalTombstones(run, plan);
|
|
909
915
|
executeJournalTombstoneDeletes(run, plan, counters);
|
|
910
916
|
|
|
911
|
-
emitScopeMaterializationGap(run, plan
|
|
917
|
+
emitScopeMaterializationGap(run, plan);
|
|
912
918
|
|
|
913
919
|
return finalizePullRun(run, plan, scopeRun, counters);
|
|
914
920
|
}
|
|
@@ -1917,27 +1923,31 @@ function finalizePullRun(
|
|
|
1917
1923
|
* remote keys as out-of-scope, emit a single summary event so the operator
|
|
1918
1924
|
* SEES that not all vault content materialized on this device and knows the
|
|
1919
1925
|
* lever — raise the membership's access level to `all`. Silent in `all` mode
|
|
1920
|
-
* (nothing is scoped away) and when
|
|
1921
|
-
* noise.
|
|
1922
|
-
*
|
|
1923
|
-
*
|
|
1924
|
-
* `
|
|
1926
|
+
* (nothing is scoped away) and when the membership scope withheld nothing: no
|
|
1927
|
+
* gap, no noise. Pure — count and samples are both read off the plan's
|
|
1928
|
+
* `skip-out-of-scope` items, no I/O.
|
|
1929
|
+
*
|
|
1930
|
+
* Counts ONLY `reason: "membership-scope"` items. `push-only` keys
|
|
1931
|
+
* (`sessions/`, US-006) are also classified `skip-out-of-scope` and also land
|
|
1932
|
+
* on the `filesOutOfScope` axis, but they are withheld in EVERY mode — `all`
|
|
1933
|
+
* included — so raising the access level does NOT materialize them. Counting
|
|
1934
|
+
* them here would inflate the gap, could fill `samplePaths` entirely with
|
|
1935
|
+
* session keys, and could fire the event (and its "set this membership to
|
|
1936
|
+
* `all`" advice) when the membership scope withheld nothing at all — sending
|
|
1937
|
+
* the operator back into exactly the flip-everything-to-`all`-and-still-miss
|
|
1938
|
+
* -files loop this event exists to end. `SyncResult.filesOutOfScope` keeps its
|
|
1939
|
+
* original both-causes meaning; only this advisory is narrowed.
|
|
1925
1940
|
*/
|
|
1926
|
-
function emitScopeMaterializationGap(
|
|
1927
|
-
run: PullRunContext,
|
|
1928
|
-
plan: PullPlan,
|
|
1929
|
-
counters: PullCounters,
|
|
1930
|
-
): void {
|
|
1941
|
+
function emitScopeMaterializationGap(run: PullRunContext, plan: PullPlan): void {
|
|
1931
1942
|
if (run.syncMode === "all") return;
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
.map((item) => item.remoteFile.key);
|
|
1943
|
+
const withheldByMembership = plan.items.filter(
|
|
1944
|
+
(item) => item.action === "skip-out-of-scope" && item.reason === "membership-scope",
|
|
1945
|
+
);
|
|
1946
|
+
if (withheldByMembership.length === 0) return;
|
|
1937
1947
|
run.emit({
|
|
1938
1948
|
type: "scope-materialization-gap",
|
|
1939
|
-
count:
|
|
1940
|
-
samplePaths,
|
|
1949
|
+
count: withheldByMembership.length,
|
|
1950
|
+
samplePaths: withheldByMembership.slice(0, 10).map((item) => item.remoteFile.key),
|
|
1941
1951
|
syncMode: run.syncMode,
|
|
1942
1952
|
});
|
|
1943
1953
|
}
|
|
@@ -2111,6 +2121,14 @@ type LocalSnapshot =
|
|
|
2111
2121
|
| { kind: "absent" | "directory" | "other" }
|
|
2112
2122
|
| { kind: "file" | "symlink"; hash: string };
|
|
2113
2123
|
|
|
2124
|
+
/**
|
|
2125
|
+
* Why a remote key was classified `skip-out-of-scope`. The two causes look
|
|
2126
|
+
* identical in the count but have OPPOSITE remedies, so they must never be
|
|
2127
|
+
* reported as one: `membership-scope` is fixed by raising the membership's
|
|
2128
|
+
* access level to `all`; `push-only` is not fixed by that at all.
|
|
2129
|
+
*/
|
|
2130
|
+
type OutOfScopeReason = "membership-scope" | "push-only";
|
|
2131
|
+
|
|
2114
2132
|
type PullPlanItem =
|
|
2115
2133
|
| {
|
|
2116
2134
|
action: "download";
|
|
@@ -2145,10 +2163,25 @@ type PullPlanItem =
|
|
|
2145
2163
|
// refused to upload these since 5.33.0; the pull walker now refuses to
|
|
2146
2164
|
// download them so legacy litter in cloud staging drains naturally.
|
|
2147
2165
|
| { action: "skip-excluded-policy"; remoteFile: RemoteFile; localPath: string }
|
|
2148
|
-
// Remote keys
|
|
2149
|
-
//
|
|
2150
|
-
//
|
|
2151
|
-
|
|
2166
|
+
// Remote keys present in the remote LIST (and accessible per STS) but
|
|
2167
|
+
// deliberately not downloaded. Two DIFFERENT causes share this action and
|
|
2168
|
+
// the `filesOutOfScope` count, so `reason` discriminates them:
|
|
2169
|
+
//
|
|
2170
|
+
// `membership-scope` — outside the effective `syncMode` prefix set
|
|
2171
|
+
// (US-005). RAISING the membership's access level to `all` WOULD
|
|
2172
|
+
// materialize these keys.
|
|
2173
|
+
// `push-only` — under a push-only exclude prefix (`sessions/`,
|
|
2174
|
+
// US-006). These are withheld in EVERY mode, `all` included; raising
|
|
2175
|
+
// the access level does NOT materialize them (`hq files get` does).
|
|
2176
|
+
//
|
|
2177
|
+
// Only `membership-scope` items may be attributed to the shared-vs-all gap
|
|
2178
|
+
// — see `emitScopeMaterializationGap`. feedback_d2082110.
|
|
2179
|
+
| {
|
|
2180
|
+
action: "skip-out-of-scope";
|
|
2181
|
+
remoteFile: RemoteFile;
|
|
2182
|
+
localPath: string;
|
|
2183
|
+
reason: OutOfScopeReason;
|
|
2184
|
+
}
|
|
2152
2185
|
| {
|
|
2153
2186
|
// Mixed-version amplifier guard — see the `skip-junk-key-spelling`
|
|
2154
2187
|
// event doc. Carries the journaled spelling for the warning surface.
|
|
@@ -2447,8 +2480,20 @@ function computePullPlan(
|
|
|
2447
2480
|
// `all` and preserves the legacy full-bucket pull bit-for-bit. The
|
|
2448
2481
|
// previously-downloaded counterparts of these keys (if scope just shrank)
|
|
2449
2482
|
// are pruned separately by the scope-shrink pass in `sync()`.
|
|
2483
|
+
// A key can fail the inclusion check AND sit under a push-only exclude
|
|
2484
|
+
// prefix. Attribute it to `push-only` in that case: widening the
|
|
2485
|
+
// membership to `all` would move it into `prefixSet` but the exclusion
|
|
2486
|
+
// below would still withhold it, so it is NOT part of the shared-vs-all
|
|
2487
|
+
// gap and must not be advertised as fixable by raising the access level.
|
|
2450
2488
|
if (!isCoveredByAny(remoteFile.key, prefixSet)) {
|
|
2451
|
-
items.push({
|
|
2489
|
+
items.push({
|
|
2490
|
+
action: "skip-out-of-scope",
|
|
2491
|
+
remoteFile,
|
|
2492
|
+
localPath,
|
|
2493
|
+
reason: isCoveredByAny(remoteFile.key, excludePrefixes)
|
|
2494
|
+
? "push-only"
|
|
2495
|
+
: "membership-scope",
|
|
2496
|
+
});
|
|
2452
2497
|
continue;
|
|
2453
2498
|
}
|
|
2454
2499
|
|
|
@@ -2462,7 +2507,12 @@ function computePullPlan(
|
|
|
2462
2507
|
// previously pulled, authored locally, or pinned+materialized) is NOT pruned
|
|
2463
2508
|
// here or by scope-shrink — see `planScopeShrink`, which never sees this set.
|
|
2464
2509
|
if (isCoveredByAny(remoteFile.key, excludePrefixes)) {
|
|
2465
|
-
items.push({
|
|
2510
|
+
items.push({
|
|
2511
|
+
action: "skip-out-of-scope",
|
|
2512
|
+
remoteFile,
|
|
2513
|
+
localPath,
|
|
2514
|
+
reason: "push-only",
|
|
2515
|
+
});
|
|
2466
2516
|
continue;
|
|
2467
2517
|
}
|
|
2468
2518
|
|
package/src/watcher.test.ts
CHANGED
|
@@ -2,11 +2,14 @@ import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
|
|
|
2
2
|
import * as fs from "fs";
|
|
3
3
|
import * as os from "os";
|
|
4
4
|
import * as path from "path";
|
|
5
|
+
import { watch } from "chokidar";
|
|
5
6
|
import {
|
|
6
7
|
FakeClock,
|
|
7
8
|
WatchPushDriver,
|
|
8
9
|
TreeWatcher,
|
|
10
|
+
ChokidarWatchBudget,
|
|
9
11
|
createWatchPathFilter,
|
|
12
|
+
toChokidarIgnored,
|
|
10
13
|
PushEventEmitter,
|
|
11
14
|
} from "./watcher.js";
|
|
12
15
|
import { StaticFlagProvider } from "./sync/feature-flags.js";
|
|
@@ -465,6 +468,87 @@ describe("US-002: createWatchPathFilter — personal-vault exclusions", () => {
|
|
|
465
468
|
});
|
|
466
469
|
});
|
|
467
470
|
|
|
471
|
+
describe("US-002: chokidar descent scope", () => {
|
|
472
|
+
let dir: string;
|
|
473
|
+
|
|
474
|
+
beforeEach(() => {
|
|
475
|
+
dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-scope-")));
|
|
476
|
+
// This is the real HQ allowlist shape. The synthetic heavy directories
|
|
477
|
+
// deliberately sit both under an in-scope company and under the two
|
|
478
|
+
// never-synced top-level buckets from #156.
|
|
479
|
+
fs.writeFileSync(path.join(dir, ".hqinclude"), "companies/*/knowledge/\n");
|
|
480
|
+
fs.mkdirSync(
|
|
481
|
+
path.join(dir, "companies", "acme", "knowledge", "node_modules", "heavy"),
|
|
482
|
+
{ recursive: true },
|
|
483
|
+
);
|
|
484
|
+
fs.writeFileSync(
|
|
485
|
+
path.join(dir, "companies", "acme", "knowledge", "node_modules", "heavy", "index.js"),
|
|
486
|
+
"module.exports = 1;\n",
|
|
487
|
+
);
|
|
488
|
+
fs.mkdirSync(path.join(dir, "repos", "checkout", "heavy"), { recursive: true });
|
|
489
|
+
fs.mkdirSync(
|
|
490
|
+
path.join(dir, "workspace", "worktrees", "repo", "node_modules", "heavy"),
|
|
491
|
+
{ recursive: true },
|
|
492
|
+
);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
afterEach(() => {
|
|
496
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("uses the directory verdict, not a file-shaped false positive, for the pre-stat probe", () => {
|
|
500
|
+
const filter = createWatchPathFilter(dir, false);
|
|
501
|
+
const nodeModules = path.join(
|
|
502
|
+
dir,
|
|
503
|
+
"companies",
|
|
504
|
+
"acme",
|
|
505
|
+
"knowledge",
|
|
506
|
+
"node_modules",
|
|
507
|
+
);
|
|
508
|
+
// `node_modules/` is directory-only. Asking the generic emit filter as
|
|
509
|
+
// though it were a file returns true, which is exactly what the old
|
|
510
|
+
// `file || directory` probe used to mistake for permission to descend.
|
|
511
|
+
expect(filter(nodeModules, false)).toBe(true);
|
|
512
|
+
expect(filter(nodeModules, true)).toBe(false);
|
|
513
|
+
const ignored = toChokidarIgnored(filter, dir);
|
|
514
|
+
expect(ignored(nodeModules)).toBe(true);
|
|
515
|
+
// The allowlist's exact ancestor matcher remains the only carve-out: the
|
|
516
|
+
// watcher can still reach companies/<slug>/knowledge without opening every
|
|
517
|
+
// otherwise-file-shaped ignored directory below it.
|
|
518
|
+
expect(ignored(path.join(dir, "companies"))).toBe(false);
|
|
519
|
+
expect(ignored(path.join(dir, "companies", "acme"))).toBe(false);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("prunes ignored directories during chokidar's pre-stat descent probe", async () => {
|
|
523
|
+
const filter = createWatchPathFilter(dir, false);
|
|
524
|
+
const chokidarWatcher = watch(dir, {
|
|
525
|
+
ignored: toChokidarIgnored(filter, dir),
|
|
526
|
+
ignoreInitial: true,
|
|
527
|
+
persistent: false,
|
|
528
|
+
});
|
|
529
|
+
try {
|
|
530
|
+
await new Promise<void>((resolve, reject) => {
|
|
531
|
+
chokidarWatcher.once("ready", resolve);
|
|
532
|
+
chokidarWatcher.once("error", reject);
|
|
533
|
+
});
|
|
534
|
+
const descended = Object.keys(chokidarWatcher.getWatched())
|
|
535
|
+
.map((watchedPath) => path.resolve(watchedPath))
|
|
536
|
+
.sort();
|
|
537
|
+
|
|
538
|
+
expect(descended).toContain(
|
|
539
|
+
path.join(dir, "companies", "acme", "knowledge"),
|
|
540
|
+
);
|
|
541
|
+
expect(descended).not.toContain(
|
|
542
|
+
path.join(dir, "companies", "acme", "knowledge", "node_modules"),
|
|
543
|
+
);
|
|
544
|
+
expect(descended).not.toContain(path.join(dir, "repos"));
|
|
545
|
+
expect(descended).not.toContain(path.join(dir, "workspace", "worktrees"));
|
|
546
|
+
} finally {
|
|
547
|
+
await chokidarWatcher.close();
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
468
552
|
describe("US-002: TreeWatcher — debounce coalesce (FakeClock seam)", () => {
|
|
469
553
|
function makeWatcher(opts?: { debounceMs?: number; personalMode?: boolean }) {
|
|
470
554
|
const clock = new FakeClock();
|
|
@@ -555,6 +639,170 @@ describe("US-002: TreeWatcher — debounce coalesce (FakeClock seam)", () => {
|
|
|
555
639
|
expect(source).not.toContain("startPollingTreeWatch");
|
|
556
640
|
expect(source).not.toContain("snapshotWatchTree");
|
|
557
641
|
});
|
|
642
|
+
|
|
643
|
+
it("stops descending at the hard watch budget, reports, and degrades to polling", () => {
|
|
644
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-cap-")));
|
|
645
|
+
fs.mkdirSync(path.join(dir, "personal", "a", "deep"), { recursive: true });
|
|
646
|
+
fs.mkdirSync(path.join(dir, "personal", "b", "deep"), { recursive: true });
|
|
647
|
+
const clock = new FakeClock();
|
|
648
|
+
const degraded = vi.fn();
|
|
649
|
+
const changed = vi.fn();
|
|
650
|
+
const descended: string[] = [];
|
|
651
|
+
let backendClosed = false;
|
|
652
|
+
try {
|
|
653
|
+
const watcher = new TreeWatcher({
|
|
654
|
+
hqRoot: dir,
|
|
655
|
+
clock,
|
|
656
|
+
debounceMs: DEBOUNCE,
|
|
657
|
+
maxWatchedPaths: 3,
|
|
658
|
+
onDegraded: degraded,
|
|
659
|
+
// This faked backend drives chokidar's real ignored predicate through
|
|
660
|
+
// its pre-stat and post-stat calls, without needing an inotify slot on
|
|
661
|
+
// a host already exhausted by another process.
|
|
662
|
+
backendFactory: ({
|
|
663
|
+
hqRoot,
|
|
664
|
+
shouldEmit,
|
|
665
|
+
maxWatchedPaths,
|
|
666
|
+
onWatchBudgetExceeded,
|
|
667
|
+
}) => {
|
|
668
|
+
const budget = new ChokidarWatchBudget(
|
|
669
|
+
maxWatchedPaths,
|
|
670
|
+
onWatchBudgetExceeded,
|
|
671
|
+
);
|
|
672
|
+
const ignored = toChokidarIgnored(shouldEmit, hqRoot, budget);
|
|
673
|
+
const visit = (candidate: string): void => {
|
|
674
|
+
if (ignored(candidate)) return;
|
|
675
|
+
const stats = fs.lstatSync(candidate);
|
|
676
|
+
if (ignored(candidate, stats)) return;
|
|
677
|
+
if (!stats.isDirectory()) return;
|
|
678
|
+
descended.push(path.resolve(candidate));
|
|
679
|
+
for (const entry of fs.readdirSync(candidate)) {
|
|
680
|
+
visit(path.join(candidate, entry));
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
visit(hqRoot);
|
|
684
|
+
return {
|
|
685
|
+
close: () => {
|
|
686
|
+
backendClosed = true;
|
|
687
|
+
},
|
|
688
|
+
needsKnownKinds: false,
|
|
689
|
+
watchedPathCount: () => descended.length,
|
|
690
|
+
};
|
|
691
|
+
},
|
|
692
|
+
});
|
|
693
|
+
watcher.onChange(changed);
|
|
694
|
+
watcher.start();
|
|
695
|
+
|
|
696
|
+
expect(descended).toHaveLength(3);
|
|
697
|
+
expect(descended).not.toContain(path.join(dir, "personal", "a", "deep"));
|
|
698
|
+
expect(degraded).toHaveBeenCalledWith(
|
|
699
|
+
expect.objectContaining({
|
|
700
|
+
reason: "watch_budget_exhausted",
|
|
701
|
+
maxWatchedPaths: 3,
|
|
702
|
+
watchedPaths: 3,
|
|
703
|
+
offendingPath: expect.stringContaining(path.join("personal", "a", "deep")),
|
|
704
|
+
}),
|
|
705
|
+
);
|
|
706
|
+
expect(backendClosed).toBe(true);
|
|
707
|
+
expect(watcher.isWatching()).toBe(false);
|
|
708
|
+
|
|
709
|
+
clock.advance(DEBOUNCE);
|
|
710
|
+
expect(changed).toHaveBeenCalledWith(
|
|
711
|
+
undefined,
|
|
712
|
+
expect.objectContaining({ overflowed: true }),
|
|
713
|
+
);
|
|
714
|
+
} finally {
|
|
715
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it("degrades instead of dying when chokidar emits runtime ENOSPC", () => {
|
|
720
|
+
const clock = new FakeClock();
|
|
721
|
+
const degraded = vi.fn();
|
|
722
|
+
const changed = vi.fn();
|
|
723
|
+
const telemetry: TelemetryEventsBatch[] = [];
|
|
724
|
+
const telemetryClient = {
|
|
725
|
+
postTelemetryEvents: vi.fn(async (batch: TelemetryEventsBatch) => {
|
|
726
|
+
telemetry.push(batch);
|
|
727
|
+
return { ok: true, written: batch.events.length, skipped: [] };
|
|
728
|
+
}),
|
|
729
|
+
};
|
|
730
|
+
let closeCalls = 0;
|
|
731
|
+
const watcher = new TreeWatcher({
|
|
732
|
+
hqRoot: ROOT,
|
|
733
|
+
clock,
|
|
734
|
+
debounceMs: DEBOUNCE,
|
|
735
|
+
onDegraded: degraded,
|
|
736
|
+
telemetryClient,
|
|
737
|
+
backendFactory: ({ onError }) => {
|
|
738
|
+
const backend = {
|
|
739
|
+
close: () => {
|
|
740
|
+
closeCalls += 1;
|
|
741
|
+
},
|
|
742
|
+
needsKnownKinds: false,
|
|
743
|
+
watchedPathCount: () => 0,
|
|
744
|
+
};
|
|
745
|
+
queueMicrotask(() =>
|
|
746
|
+
onError(
|
|
747
|
+
Object.assign(new Error("inotify limit"), {
|
|
748
|
+
code: "ENOSPC",
|
|
749
|
+
path: path.join(ROOT, "companies", "acme", "knowledge"),
|
|
750
|
+
}),
|
|
751
|
+
),
|
|
752
|
+
);
|
|
753
|
+
return backend;
|
|
754
|
+
},
|
|
755
|
+
});
|
|
756
|
+
watcher.onChange(changed);
|
|
757
|
+
watcher.start();
|
|
758
|
+
|
|
759
|
+
return new Promise<void>((resolve) => queueMicrotask(resolve)).then(() => {
|
|
760
|
+
expect(closeCalls).toBe(1);
|
|
761
|
+
expect(watcher.isWatching()).toBe(false);
|
|
762
|
+
expect(degraded).toHaveBeenCalledWith(
|
|
763
|
+
expect.objectContaining({
|
|
764
|
+
reason: "inotify_enospc",
|
|
765
|
+
errorCode: "ENOSPC",
|
|
766
|
+
offendingPath: path.join(ROOT, "companies", "acme", "knowledge"),
|
|
767
|
+
}),
|
|
768
|
+
);
|
|
769
|
+
expect(telemetry).toEqual([
|
|
770
|
+
expect.objectContaining({
|
|
771
|
+
events: [
|
|
772
|
+
expect.objectContaining({
|
|
773
|
+
eventName: "watcher_degraded",
|
|
774
|
+
source: "watcher",
|
|
775
|
+
properties: expect.objectContaining({
|
|
776
|
+
reason: "inotify_enospc",
|
|
777
|
+
offendingTopLevel: "companies",
|
|
778
|
+
}),
|
|
779
|
+
}),
|
|
780
|
+
],
|
|
781
|
+
}),
|
|
782
|
+
]);
|
|
783
|
+
clock.advance(DEBOUNCE);
|
|
784
|
+
expect(changed).toHaveBeenCalledWith(
|
|
785
|
+
undefined,
|
|
786
|
+
expect.objectContaining({ overflowed: true }),
|
|
787
|
+
);
|
|
788
|
+
});
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
it("does not retain one rename-kind entry for every changed file", () => {
|
|
792
|
+
const clock = new FakeClock();
|
|
793
|
+
const watcher = new TreeWatcher({
|
|
794
|
+
hqRoot: ROOT,
|
|
795
|
+
clock,
|
|
796
|
+
pathFilter: () => true,
|
|
797
|
+
maxPendingPaths: 20_000,
|
|
798
|
+
maxPendingBytes: 20_000_000,
|
|
799
|
+
});
|
|
800
|
+
for (let i = 0; i < 10_000; i++) {
|
|
801
|
+
watcher.handleEvent(path.join(ROOT, "personal", `file-${i}.md`));
|
|
802
|
+
}
|
|
803
|
+
expect(watcher.knownDirectoryCount()).toBe(0);
|
|
804
|
+
watcher.stop();
|
|
805
|
+
});
|
|
558
806
|
});
|
|
559
807
|
|
|
560
808
|
describe("US-002: TreeWatcher — lifecycle (real chokidar over a temp dir)", () => {
|