@indigoai-us/hq-cloud 6.14.26 → 6.14.28
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-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +16 -5
- package/dist/bin/sync-runner-company.js.map +1 -1
- 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/bin/sync-runner.test.js +11 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/personal-vault.d.ts +21 -4
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +63 -4
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.d.ts +3 -2
- package/dist/personal-vault.test.d.ts.map +1 -1
- package/dist/personal-vault.test.js +36 -3
- package/dist/personal-vault.test.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-company.ts +19 -5
- package/src/bin/sync-runner-watch-loop.ts +15 -5
- package/src/bin/sync-runner.test.ts +17 -1
- package/src/personal-vault.test.ts +41 -2
- package/src/personal-vault.ts +63 -4
- package/src/watcher.test.ts +248 -0
- package/src/watcher.ts +322 -42
package/src/personal-vault.ts
CHANGED
|
@@ -87,7 +87,8 @@ export interface PersonalVaultOptions {
|
|
|
87
87
|
* `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL`.
|
|
88
88
|
* 2. Every local (non-cloud) `companies/{slug}/` subdir, after excluding
|
|
89
89
|
* (a) `_template`, (b) slugs already in `opts.teamSyncedSlugs`
|
|
90
|
-
* (cloud-backed — they sync to their own team bucket),
|
|
90
|
+
* (cloud-backed — they sync to their own team bucket), (c) slugs with
|
|
91
|
+
* `cloud_uid: cmp_*` in `companies/manifest.yaml`, and (d) any
|
|
91
92
|
* subdir explicitly marked `cloud: true` in its `company.yaml`.
|
|
92
93
|
* A company with no `company.yaml` (or one without a `cloud:` marker)
|
|
93
94
|
* is included — local companies sync to the personal vault by default.
|
|
@@ -363,12 +364,19 @@ export function computeSessionLogsSyncPaths(hqRoot: string): string[] {
|
|
|
363
364
|
* (currently just `_template`).
|
|
364
365
|
* 3. The slug is NOT in `teamSyncedSlugs` (membership-backed slugs sync
|
|
365
366
|
* to their own bucket and must not be double-written).
|
|
366
|
-
* 4.
|
|
367
|
+
* 4. The slug is NOT listed with a `cloud_uid: cmp_*` in
|
|
368
|
+
* `companies/manifest.yaml`. Full-HQ agent boxes often run
|
|
369
|
+
* `--personal` alone (no company fanout targets), so
|
|
370
|
+
* `teamSyncedSlugs` is empty even though indigo (etc.) is
|
|
371
|
+
* cloud-backed; without this check the multi-GB company tree is
|
|
372
|
+
* re-mirrored into the agent personal vault every ritual and
|
|
373
|
+
* times out the 180s personal push.
|
|
374
|
+
* 5. `companies/{slug}/company.yaml` does NOT contain an explicit
|
|
367
375
|
* `cloud: true` line. Local companies sync to the personal vault by
|
|
368
376
|
* default, so a missing/markerless `company.yaml` is INCLUDED; only an
|
|
369
377
|
* explicit `cloud: true` marker opts the directory OUT (belt-and-braces
|
|
370
|
-
* with `teamSyncedSlugs` for a company designated
|
|
371
|
-
* its membership has resolved).
|
|
378
|
+
* with `teamSyncedSlugs` / manifest `cloud_uid` for a company designated
|
|
379
|
+
* cloud-backed before its membership has resolved).
|
|
372
380
|
*
|
|
373
381
|
* Returns absolute paths.
|
|
374
382
|
*/
|
|
@@ -383,10 +391,16 @@ export function computePersonalCompanySubdirs(
|
|
|
383
391
|
} catch {
|
|
384
392
|
return [];
|
|
385
393
|
}
|
|
394
|
+
// Manifest cloud_uid is the durable routing signal for cloud-backed
|
|
395
|
+
// companies. Prefer it over relying solely on runner membership fanout
|
|
396
|
+
// (empty for --personal-only agent ritual) or company.yaml (often missing
|
|
397
|
+
// on full-HQ agent boxes that never ran /designate-team locally).
|
|
398
|
+
const cloudManifestSlugs = readCloudBackedSlugsFromManifest(hqRoot);
|
|
386
399
|
const eligible: string[] = [];
|
|
387
400
|
for (const slug of slugs) {
|
|
388
401
|
if (PERSONAL_VAULT_COMPANY_EXCLUDED_SLUGS.includes(slug)) continue;
|
|
389
402
|
if (teamSyncedSlugs.has(slug)) continue;
|
|
403
|
+
if (cloudManifestSlugs.has(slug)) continue;
|
|
390
404
|
const subdir = path.join(companiesRoot, slug);
|
|
391
405
|
let isDir = false;
|
|
392
406
|
try {
|
|
@@ -401,6 +415,51 @@ export function computePersonalCompanySubdirs(
|
|
|
401
415
|
return eligible;
|
|
402
416
|
}
|
|
403
417
|
|
|
418
|
+
/**
|
|
419
|
+
* Slugs whose `companies/manifest.yaml` entry carries a `cloud_uid: cmp_*`.
|
|
420
|
+
* These are cloud-backed team companies and must not personal-vault mirror.
|
|
421
|
+
*
|
|
422
|
+
* Lightweight line parse (no YAML dep): fail-open on missing/unreadable
|
|
423
|
+
* manifest → empty set → companies stay eligible for personal fallback.
|
|
424
|
+
* Only `cmp_*` uids count (same rule as company-resolver / telemetry).
|
|
425
|
+
*/
|
|
426
|
+
export function readCloudBackedSlugsFromManifest(hqRoot: string): Set<string> {
|
|
427
|
+
const manifestPath = path.join(hqRoot, "companies", "manifest.yaml");
|
|
428
|
+
let text: string;
|
|
429
|
+
try {
|
|
430
|
+
text = fs.readFileSync(manifestPath, "utf8");
|
|
431
|
+
} catch {
|
|
432
|
+
return new Set();
|
|
433
|
+
}
|
|
434
|
+
const out = new Set<string>();
|
|
435
|
+
let currentSlug: string | null = null;
|
|
436
|
+
let inCompanies = false;
|
|
437
|
+
for (const line of text.split(/\r?\n/)) {
|
|
438
|
+
// Top-level key (no indent). Only track entries under `companies:`.
|
|
439
|
+
if (/^[A-Za-z0-9_.-]+:\s*(?:#.*)?$/.test(line)) {
|
|
440
|
+
inCompanies = /^companies:\s*(?:#.*)?$/.test(line);
|
|
441
|
+
currentSlug = null;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (!inCompanies) continue;
|
|
445
|
+
// Two-space indent: company slug under companies:
|
|
446
|
+
const slugMatch = /^ {2}([A-Za-z0-9][A-Za-z0-9_-]*):\s*(?:#.*)?$/.exec(line);
|
|
447
|
+
if (slugMatch) {
|
|
448
|
+
currentSlug = slugMatch[1];
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
if (
|
|
452
|
+
currentSlug &&
|
|
453
|
+
/^\s{2,}cloud_uid:\s*cmp_[A-Za-z0-9]+\b/.test(line)
|
|
454
|
+
) {
|
|
455
|
+
out.add(currentSlug);
|
|
456
|
+
// Keep currentSlug so a later key on the same company is fine; next
|
|
457
|
+
// sibling slug line will replace it.
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return out;
|
|
461
|
+
}
|
|
462
|
+
|
|
404
463
|
/**
|
|
405
464
|
* True iff `{subdir}/company.yaml` exists and contains an explicit
|
|
406
465
|
* top-level `cloud: true` line — the marker `/designate-team` writes when a
|
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)", () => {
|