@indigoai-us/hq-cloud 6.14.49 → 6.15.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/unreleased-commits-nag.yml +256 -0
- package/dist/bin/sync-runner-watch-loop.d.ts +12 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +153 -25
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +17 -0
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +11 -0
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.test.d.ts +2 -0
- package/dist/bin/sync-runner-watch-routes.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-watch-routes.test.js +51 -0
- package/dist/bin/sync-runner-watch-routes.test.js.map +1 -0
- package/dist/bin/sync-runner.d.ts +11 -12
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +173 -52
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex-knowledge.d.ts +7 -0
- package/dist/cli/reindex-knowledge.d.ts.map +1 -0
- package/dist/cli/reindex-knowledge.js +389 -0
- package/dist/cli/reindex-knowledge.js.map +1 -0
- package/dist/cli/reindex-knowledge.test.d.ts +12 -0
- package/dist/cli/reindex-knowledge.test.d.ts.map +1 -0
- package/dist/cli/reindex-knowledge.test.js +260 -0
- package/dist/cli/reindex-knowledge.test.js.map +1 -0
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +8 -0
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +8 -2
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +85 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +390 -1
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-settings-reconcile.test.d.ts +2 -0
- package/dist/cli/rescue-settings-reconcile.test.d.ts.map +1 -0
- package/dist/cli/rescue-settings-reconcile.test.js +523 -0
- package/dist/cli/rescue-settings-reconcile.test.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +81 -51
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +11 -19
- package/dist/journal.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +8 -25
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +23 -48
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +6 -32
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/feature-flags.d.ts +20 -117
- package/dist/sync/feature-flags.d.ts.map +1 -1
- package/dist/sync/feature-flags.js +24 -147
- package/dist/sync/feature-flags.js.map +1 -1
- package/dist/sync/feature-flags.test.js +15 -61
- package/dist/sync/feature-flags.test.js.map +1 -1
- package/dist/sync/index.d.ts +1 -1
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -1
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/lease-client.d.ts +67 -0
- package/dist/sync/lease-client.d.ts.map +1 -0
- package/dist/sync/lease-client.js +158 -0
- package/dist/sync/lease-client.js.map +1 -0
- package/dist/sync/lease-client.test.d.ts +2 -0
- package/dist/sync/lease-client.test.d.ts.map +1 -0
- package/dist/sync/lease-client.test.js +119 -0
- package/dist/sync/lease-client.test.js.map +1 -0
- package/dist/sync/push-receiver.d.ts +8 -19
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +6 -50
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +6 -57
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync/realtime-rollout.d.ts +85 -0
- package/dist/sync/realtime-rollout.d.ts.map +1 -0
- package/dist/sync/realtime-rollout.js +206 -0
- package/dist/sync/realtime-rollout.js.map +1 -0
- package/dist/sync/realtime-rollout.test.d.ts +2 -0
- package/dist/sync/realtime-rollout.test.d.ts.map +1 -0
- package/dist/sync/realtime-rollout.test.js +71 -0
- package/dist/sync/realtime-rollout.test.js.map +1 -0
- package/dist/sync/state-store.d.ts +40 -0
- package/dist/sync/state-store.d.ts.map +1 -0
- package/dist/sync/state-store.js +680 -0
- package/dist/sync/state-store.js.map +1 -0
- package/dist/sync/state-store.test.d.ts +2 -0
- package/dist/sync/state-store.test.d.ts.map +1 -0
- package/dist/sync/state-store.test.js +159 -0
- package/dist/sync/state-store.test.js.map +1 -0
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-watch-loop.ts +176 -32
- package/src/bin/sync-runner-watch-routes.test.ts +71 -0
- package/src/bin/sync-runner-watch-routes.ts +27 -0
- package/src/bin/sync-runner.test.ts +188 -53
- package/src/bin/sync-runner.ts +11 -12
- package/src/cli/reindex-knowledge.test.ts +307 -0
- package/src/cli/reindex-knowledge.ts +450 -0
- package/src/cli/reindex.ts +9 -0
- package/src/cli/rescue-classify-ordering.test.ts +9 -3
- package/src/cli/rescue-core.ts +459 -1
- package/src/cli/rescue-settings-reconcile.test.ts +637 -0
- package/src/index.ts +2 -4
- package/src/journal.test.ts +19 -21
- package/src/journal.ts +87 -54
- package/src/sync/event-sync.test.ts +9 -44
- package/src/sync/event-sync.ts +26 -52
- package/src/sync/feature-flags.test.ts +22 -72
- package/src/sync/feature-flags.ts +32 -199
- package/src/sync/index.ts +2 -4
- package/src/sync/lease-client.test.ts +128 -0
- package/src/sync/lease-client.ts +207 -0
- package/src/sync/push-receiver.test.ts +6 -58
- package/src/sync/push-receiver.ts +9 -74
- package/src/sync/realtime-rollout.test.ts +86 -0
- package/src/sync/realtime-rollout.ts +262 -0
- package/src/sync/state-store.test.ts +194 -0
- package/src/sync/state-store.ts +727 -0
- package/src/types.ts +14 -0
- package/test/e2e/sync/transient-company-leg.test.ts +8 -4
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as os from "os";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import * as fs from "fs";
|
|
4
|
+
import { performance } from "node:perf_hooks";
|
|
4
5
|
import { inventoryManagedSnapshots } from "../backup-prune.js";
|
|
5
6
|
import { VaultClient } from "../index.js";
|
|
6
7
|
import { pickCanonicalPersonEntity } from "../vault-client.js";
|
|
@@ -44,6 +45,7 @@ import {
|
|
|
44
45
|
buildScopedDrainPullArgv,
|
|
45
46
|
buildScopedPushArgv,
|
|
46
47
|
buildTargetedPullArgv,
|
|
48
|
+
isWatchRouteSelected,
|
|
47
49
|
routeChangeToTarget,
|
|
48
50
|
routeKey,
|
|
49
51
|
type WatchRoute,
|
|
@@ -91,9 +93,35 @@ export function adaptivePollMs(
|
|
|
91
93
|
return Math.round(floorMs + ratio * (ceilMs - floorMs));
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Advance the cadence from its previous monotonic deadline, rather than from
|
|
98
|
+
* the end of a pass. A slow pass is allowed one immediate catch-up pass; the
|
|
99
|
+
* returned deadline is always the first future slot, so missed slots cannot
|
|
100
|
+
* turn into an unbounded burst.
|
|
101
|
+
*/
|
|
102
|
+
export function advanceMonotonicDeadline(
|
|
103
|
+
previousDueAt: number,
|
|
104
|
+
now: number,
|
|
105
|
+
intervalMs: number,
|
|
106
|
+
): { nextDueAt: number; delayMs: number } {
|
|
107
|
+
let nextDueAt = previousDueAt + intervalMs;
|
|
108
|
+
if (nextDueAt > now) {
|
|
109
|
+
return { nextDueAt, delayMs: nextDueAt - now };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The pass crossed at least one deadline. Run exactly one catch-up now,
|
|
113
|
+
// then retain only the first future cadence slot.
|
|
114
|
+
do {
|
|
115
|
+
nextDueAt += intervalMs;
|
|
116
|
+
} while (nextDueAt <= now);
|
|
117
|
+
return { nextDueAt, delayMs: 0 };
|
|
118
|
+
}
|
|
119
|
+
|
|
94
120
|
export interface ParsedLoopArgs {
|
|
95
121
|
hqRoot: string;
|
|
96
122
|
lockTimeoutSec?: number;
|
|
123
|
+
/** Resolved parser flag; environment policy is applied by resolveSkipPersonal. */
|
|
124
|
+
skipPersonal?: boolean;
|
|
97
125
|
/**
|
|
98
126
|
* The run's resolved sync direction, straight from `parseArgs` (the single
|
|
99
127
|
* source of truth — same default as the parser). The drain reads this to
|
|
@@ -220,6 +248,7 @@ export async function runWatchLoop(
|
|
|
220
248
|
const sleep =
|
|
221
249
|
deps.sleep ??
|
|
222
250
|
((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
251
|
+
const monotonicNow = deps.monotonicNow ?? (() => performance.now());
|
|
223
252
|
const runPass =
|
|
224
253
|
deps.runPass ?? runtime.runPassWithOperationLockAlreadyHeld;
|
|
225
254
|
const pollIdx = argv.indexOf("--poll-remote-ms");
|
|
@@ -232,6 +261,22 @@ export async function runWatchLoop(
|
|
|
232
261
|
const cpuCount = os.cpus().length;
|
|
233
262
|
const eventPush = argv.includes("--event-push");
|
|
234
263
|
const companiesMode = argv.includes("--companies");
|
|
264
|
+
const companyMode = argv.includes("--company");
|
|
265
|
+
const personalMode = argv.includes("--personal");
|
|
266
|
+
// Keep this at the watcher ingress rather than importing the runner's
|
|
267
|
+
// resolver: sync-runner imports this loop, and a reverse value import would
|
|
268
|
+
// create a runtime cycle. These are the parser's documented truthy values.
|
|
269
|
+
const skipPersonalEnv = (process.env.HQ_SYNC_SKIP_PERSONAL ?? "").toLowerCase();
|
|
270
|
+
const watchRouteSelection = {
|
|
271
|
+
companies: companiesMode,
|
|
272
|
+
company: companyMode,
|
|
273
|
+
personal: personalMode,
|
|
274
|
+
skipPersonal:
|
|
275
|
+
parsed.skipPersonal === true ||
|
|
276
|
+
skipPersonalEnv === "1" ||
|
|
277
|
+
skipPersonalEnv === "true" ||
|
|
278
|
+
skipPersonalEnv === "yes",
|
|
279
|
+
};
|
|
235
280
|
const hqRoot = parsed.hqRoot;
|
|
236
281
|
// Delayed and daily: this must never be attached to the 15s poll cadence.
|
|
237
282
|
// Stage 0 remains dry-run only; the standalone hq-backup-prune command is
|
|
@@ -323,6 +368,45 @@ export async function runWatchLoop(
|
|
|
323
368
|
};
|
|
324
369
|
|
|
325
370
|
let stopped = false;
|
|
371
|
+
let resolveStopped: (() => void) | null = null;
|
|
372
|
+
const stoppedSignal = new Promise<void>((resolve) => {
|
|
373
|
+
resolveStopped = resolve;
|
|
374
|
+
});
|
|
375
|
+
// A signal can arrive while a guarded pass is running, before the next wait
|
|
376
|
+
// has registered. Keep a monotonic generation so that event is retained and
|
|
377
|
+
// consumed by the next scheduler wait instead of being lost.
|
|
378
|
+
let interruptGeneration = 0;
|
|
379
|
+
let observedInterruptGeneration = 0;
|
|
380
|
+
let resolveInterruptWait: (() => void) | null = null;
|
|
381
|
+
const interruptWait = (): void => {
|
|
382
|
+
if (stopped) return;
|
|
383
|
+
interruptGeneration += 1;
|
|
384
|
+
const resolve = resolveInterruptWait;
|
|
385
|
+
resolveInterruptWait = null;
|
|
386
|
+
resolve?.();
|
|
387
|
+
};
|
|
388
|
+
const waitForNextPass = async (delayMs: number): Promise<void> => {
|
|
389
|
+
if (interruptGeneration !== observedInterruptGeneration) {
|
|
390
|
+
observedInterruptGeneration = interruptGeneration;
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
await Promise.race([
|
|
395
|
+
sleep(delayMs),
|
|
396
|
+
new Promise<void>((resolve) => {
|
|
397
|
+
resolveInterruptWait = resolve;
|
|
398
|
+
// Do not lose an event that arrived between the first generation
|
|
399
|
+
// check and registration of this wait.
|
|
400
|
+
if (interruptGeneration !== observedInterruptGeneration) {
|
|
401
|
+
resolveInterruptWait = null;
|
|
402
|
+
resolve();
|
|
403
|
+
}
|
|
404
|
+
}),
|
|
405
|
+
stoppedSignal,
|
|
406
|
+
]);
|
|
407
|
+
resolveInterruptWait = null;
|
|
408
|
+
observedInterruptGeneration = interruptGeneration;
|
|
409
|
+
};
|
|
326
410
|
let activePass: Promise<number | RunnerPassOutcome> | null = null;
|
|
327
411
|
type QueuedPass = {
|
|
328
412
|
argv: string[];
|
|
@@ -384,6 +468,13 @@ export async function runWatchLoop(
|
|
|
384
468
|
const pendingWatcherPaths = new Map<string, string>();
|
|
385
469
|
const pendingWatcherChanges = new Map<string, TreeChange>();
|
|
386
470
|
let pendingWatcherOriginalBatch: TreeChangeBatch | null = null;
|
|
471
|
+
// Every coalesced watcher batch gets one durable in-process generation.
|
|
472
|
+
// A wake can cause the scheduler to revisit a drained batch, but an event
|
|
473
|
+
// announcement is an external side effect and must occur at most once for
|
|
474
|
+
// that batch after its targeted push succeeds.
|
|
475
|
+
let nextWatcherBatchGeneration = 0;
|
|
476
|
+
let pendingWatcherBatchGeneration: number | null = null;
|
|
477
|
+
const lastPublishedWatcherBatchGenerationByRoute = new Map<string, number>();
|
|
387
478
|
let pendingWatcherBareChange = false;
|
|
388
479
|
let pendingWatcherOverflowed = false;
|
|
389
480
|
let pendingWatcherDroppedPaths = 0;
|
|
@@ -391,7 +482,30 @@ export async function runWatchLoop(
|
|
|
391
482
|
const addPendingWatcherChange = (
|
|
392
483
|
changedRelPath?: string,
|
|
393
484
|
batch?: TreeChangeBatch,
|
|
394
|
-
):
|
|
485
|
+
): boolean => {
|
|
486
|
+
if (batch) {
|
|
487
|
+
const paths = new Map<string, string>();
|
|
488
|
+
const changes = new Map<string, TreeChange>();
|
|
489
|
+
for (const [absolutePath, relativePath] of batch.paths.entries()) {
|
|
490
|
+
const route = routeChangeToTarget(relativePath);
|
|
491
|
+
if (!route || !isWatchRouteSelected(route, watchRouteSelection)) continue;
|
|
492
|
+
paths.set(absolutePath, relativePath);
|
|
493
|
+
const change = batch.changes?.get(absolutePath);
|
|
494
|
+
if (change) changes.set(absolutePath, change);
|
|
495
|
+
}
|
|
496
|
+
if (paths.size === 0) return false;
|
|
497
|
+
batch = {
|
|
498
|
+
...batch,
|
|
499
|
+
paths,
|
|
500
|
+
changes,
|
|
501
|
+
};
|
|
502
|
+
} else if (changedRelPath) {
|
|
503
|
+
const route = routeChangeToTarget(changedRelPath);
|
|
504
|
+
if (!route || !isWatchRouteSelected(route, watchRouteSelection)) {
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
pendingWatcherBatchGeneration ??= ++nextWatcherBatchGeneration;
|
|
395
509
|
if (batch) {
|
|
396
510
|
pendingWatcherOriginalBatch =
|
|
397
511
|
pendingWatcherPaths.size === 0 &&
|
|
@@ -410,7 +524,7 @@ export async function runWatchLoop(
|
|
|
410
524
|
pendingWatcherDroppedPaths += batch.droppedPaths ?? 0;
|
|
411
525
|
pendingWatcherDroppedBytes += batch.droppedBytes ?? 0;
|
|
412
526
|
}
|
|
413
|
-
return;
|
|
527
|
+
return true;
|
|
414
528
|
}
|
|
415
529
|
if (changedRelPath) {
|
|
416
530
|
pendingWatcherOriginalBatch = null;
|
|
@@ -424,14 +538,16 @@ export async function runWatchLoop(
|
|
|
424
538
|
// a prior batch left for this path so the stale intent cannot leak
|
|
425
539
|
// into the coalesced batch (#227).
|
|
426
540
|
pendingWatcherChanges.delete(absolutePath);
|
|
427
|
-
return;
|
|
541
|
+
return true;
|
|
428
542
|
}
|
|
429
543
|
pendingWatcherOriginalBatch = null;
|
|
430
544
|
pendingWatcherBareChange = true;
|
|
545
|
+
return true;
|
|
431
546
|
};
|
|
432
547
|
const takePendingWatcherChange = (): {
|
|
433
548
|
batch: TreeChangeBatch | null;
|
|
434
549
|
bare: boolean;
|
|
550
|
+
generation: number | null;
|
|
435
551
|
} => {
|
|
436
552
|
const batch =
|
|
437
553
|
pendingWatcherOriginalBatch !== null && !pendingWatcherOverflowed
|
|
@@ -452,6 +568,7 @@ export async function runWatchLoop(
|
|
|
452
568
|
}
|
|
453
569
|
: null;
|
|
454
570
|
const bare = pendingWatcherBareChange;
|
|
571
|
+
const generation = pendingWatcherBatchGeneration;
|
|
455
572
|
pendingWatcherPaths.clear();
|
|
456
573
|
pendingWatcherChanges.clear();
|
|
457
574
|
pendingWatcherOriginalBatch = null;
|
|
@@ -459,9 +576,15 @@ export async function runWatchLoop(
|
|
|
459
576
|
pendingWatcherOverflowed = false;
|
|
460
577
|
pendingWatcherDroppedPaths = 0;
|
|
461
578
|
pendingWatcherDroppedBytes = 0;
|
|
462
|
-
|
|
579
|
+
pendingWatcherBatchGeneration = null;
|
|
580
|
+
return { batch, bare, generation };
|
|
463
581
|
};
|
|
464
|
-
const restorePendingWatcherPaths = (
|
|
582
|
+
const restorePendingWatcherPaths = (
|
|
583
|
+
paths: Map<string, string>,
|
|
584
|
+
generation: number | null,
|
|
585
|
+
): void => {
|
|
586
|
+
pendingWatcherBatchGeneration ??=
|
|
587
|
+
generation ?? ++nextWatcherBatchGeneration;
|
|
465
588
|
pendingWatcherOriginalBatch = null;
|
|
466
589
|
for (const [absolutePath, relativePath] of paths.entries()) {
|
|
467
590
|
pendingWatcherPaths.set(absolutePath, relativePath);
|
|
@@ -512,7 +635,7 @@ export async function runWatchLoop(
|
|
|
512
635
|
relativePath: string,
|
|
513
636
|
): { slug: string; key: string } | null => {
|
|
514
637
|
const route = routeChangeToTarget(relativePath);
|
|
515
|
-
if (!route) return null;
|
|
638
|
+
if (!route || !isWatchRouteSelected(route, watchRouteSelection)) return null;
|
|
516
639
|
if (route.kind === "personal") {
|
|
517
640
|
return { slug: PERSONAL_VAULT_JOURNAL_SLUG, key: relativePath };
|
|
518
641
|
}
|
|
@@ -786,14 +909,17 @@ export async function runWatchLoop(
|
|
|
786
909
|
return grouped;
|
|
787
910
|
};
|
|
788
911
|
|
|
789
|
-
const publishBatchIfAvailable = (paths: Map<string, string>):
|
|
912
|
+
const publishBatchIfAvailable = (paths: Map<string, string>): boolean => {
|
|
790
913
|
if (!stopped && eventSync && paths.size > 0) {
|
|
791
914
|
eventSync.publishBatch({ paths: new Map(paths) });
|
|
915
|
+
return true;
|
|
792
916
|
}
|
|
917
|
+
return false;
|
|
793
918
|
};
|
|
794
919
|
|
|
795
920
|
const runScopedDrain = async (
|
|
796
921
|
batch: TreeChangeBatch | null,
|
|
922
|
+
watcherBatchGeneration: number | null,
|
|
797
923
|
): Promise<number> => {
|
|
798
924
|
if (drainRunsPush && batch && batch.paths.size > 0) {
|
|
799
925
|
const skipCompanies = resolveSkipCompanies();
|
|
@@ -828,26 +954,35 @@ export async function runWatchLoop(
|
|
|
828
954
|
.filter((pathResult) => pathResult.status === "refused")
|
|
829
955
|
.map((pathResult) => pathResult.relativePath),
|
|
830
956
|
);
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
957
|
+
const route = routeKey(group.route);
|
|
958
|
+
const generation = watcherBatchGeneration ?? 0;
|
|
959
|
+
if (
|
|
960
|
+
lastPublishedWatcherBatchGenerationByRoute.get(route) !== generation
|
|
961
|
+
) {
|
|
962
|
+
const published = publishBatchIfAvailable(
|
|
963
|
+
new Map(
|
|
964
|
+
[...group.paths.entries()].filter(
|
|
965
|
+
([absolutePath, relativePath]) => {
|
|
966
|
+
if (refusedPaths.has(relativePath)) return false;
|
|
967
|
+
if (group.changes.get(absolutePath)?.kind !== "unlinkDir") {
|
|
968
|
+
return true;
|
|
969
|
+
}
|
|
970
|
+
return ![...refusedPaths].some((refusedPath) =>
|
|
971
|
+
refusedPath.startsWith(`${relativePath}/`),
|
|
972
|
+
);
|
|
973
|
+
},
|
|
974
|
+
),
|
|
843
975
|
),
|
|
844
|
-
)
|
|
845
|
-
|
|
976
|
+
);
|
|
977
|
+
if (published) {
|
|
978
|
+
lastPublishedWatcherBatchGenerationByRoute.set(route, generation);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
846
981
|
} else {
|
|
847
|
-
restorePendingWatcherPaths(group.paths);
|
|
982
|
+
restorePendingWatcherPaths(group.paths, watcherBatchGeneration);
|
|
848
983
|
}
|
|
849
984
|
} catch (err) {
|
|
850
|
-
restorePendingWatcherPaths(group.paths);
|
|
985
|
+
restorePendingWatcherPaths(group.paths, watcherBatchGeneration);
|
|
851
986
|
process.stderr.write(
|
|
852
987
|
`watch scoped push failed, will retry next tick: ${describeError(err)}\n`,
|
|
853
988
|
);
|
|
@@ -901,12 +1036,17 @@ export async function runWatchLoop(
|
|
|
901
1036
|
|
|
902
1037
|
watcher.onChange((changedRelPath, batch) => {
|
|
903
1038
|
if (stopped) return;
|
|
904
|
-
addPendingWatcherChange(changedRelPath, batch);
|
|
1039
|
+
if (addPendingWatcherChange(changedRelPath, batch)) interruptWait();
|
|
905
1040
|
});
|
|
906
1041
|
watcher.start();
|
|
907
1042
|
|
|
908
1043
|
const receiverSyncFn: SyncEngineFn = async (ctx) => {
|
|
909
1044
|
if (stopped) return;
|
|
1045
|
+
// A received wake/high-water signal means there is bounded work now.
|
|
1046
|
+
// It wakes the scheduler but is still funneled through runGuarded below,
|
|
1047
|
+
// so it cannot overlap the pass that may currently own the operation
|
|
1048
|
+
// lock.
|
|
1049
|
+
interruptWait();
|
|
910
1050
|
const route = routeChangeToTarget(ctx.event.relativePath);
|
|
911
1051
|
if (!route) return;
|
|
912
1052
|
const targetedArgv = buildTargetedPullArgv(route, passArgv);
|
|
@@ -921,8 +1061,7 @@ export async function runWatchLoop(
|
|
|
921
1061
|
receiver = createReceiver({ syncFn: receiverSyncFn, hqRoot });
|
|
922
1062
|
void Promise.resolve(receiver.start()).catch(() => undefined);
|
|
923
1063
|
|
|
924
|
-
|
|
925
|
-
if (resolveEventSync(email, process.env.HQ_SYNC_EVENT_SYNC)) {
|
|
1064
|
+
if (resolveEventSync(process.env.HQ_SYNC_EVENT_SYNC)) {
|
|
926
1065
|
const startES = deps.startEventSync ?? defaultStartEventSync;
|
|
927
1066
|
void (async () => {
|
|
928
1067
|
const eventSyncClient = new VaultClient({
|
|
@@ -962,10 +1101,6 @@ export async function runWatchLoop(
|
|
|
962
1101
|
}
|
|
963
1102
|
}
|
|
964
1103
|
|
|
965
|
-
let resolveStopped: (() => void) | null = null;
|
|
966
|
-
const stoppedSignal = new Promise<void>((resolve) => {
|
|
967
|
-
resolveStopped = resolve;
|
|
968
|
-
});
|
|
969
1104
|
const shutdown = (): void => {
|
|
970
1105
|
if (stopped) return;
|
|
971
1106
|
stopped = true;
|
|
@@ -1001,6 +1136,9 @@ export async function runWatchLoop(
|
|
|
1001
1136
|
});
|
|
1002
1137
|
detachSignal = onShutdownSignal(shutdown);
|
|
1003
1138
|
|
|
1139
|
+
// The initial reconcile is due immediately. Later passes advance from this
|
|
1140
|
+
// deadline, not from pass completion, so cadence stays monotonic.
|
|
1141
|
+
let nextDueAt = monotonicNow();
|
|
1004
1142
|
let pollTick = 0;
|
|
1005
1143
|
try {
|
|
1006
1144
|
while (!stopped) {
|
|
@@ -1022,7 +1160,7 @@ export async function runWatchLoop(
|
|
|
1022
1160
|
pollTick % FULL_RECONCILE_EVERY_TICKS === 0;
|
|
1023
1161
|
const result = shouldRunFull
|
|
1024
1162
|
? await runGuarded(passArgv)
|
|
1025
|
-
: await runScopedDrain(pending.batch);
|
|
1163
|
+
: await runScopedDrain(pending.batch, pending.generation);
|
|
1026
1164
|
// This batch's episode has now been prepared and pushed, so a later
|
|
1027
1165
|
// removal under the same path is judged fresh — a root that never
|
|
1028
1166
|
// expired would silently refuse ordinary deletes from here on.
|
|
@@ -1081,7 +1219,13 @@ export async function runWatchLoop(
|
|
|
1081
1219
|
}
|
|
1082
1220
|
const nextPollMs =
|
|
1083
1221
|
explicitPollMs ?? adaptivePollMs(sampleLoadAvg(), cpuCount);
|
|
1084
|
-
|
|
1222
|
+
const schedule = advanceMonotonicDeadline(
|
|
1223
|
+
nextDueAt,
|
|
1224
|
+
monotonicNow(),
|
|
1225
|
+
nextPollMs,
|
|
1226
|
+
);
|
|
1227
|
+
nextDueAt = schedule.nextDueAt;
|
|
1228
|
+
await waitForNextPass(schedule.delayMs);
|
|
1085
1229
|
}
|
|
1086
1230
|
return 0;
|
|
1087
1231
|
} finally {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
isWatchRouteSelected,
|
|
4
|
+
routeChangeToTarget,
|
|
5
|
+
type WatchRouteSelection,
|
|
6
|
+
} from "./sync-runner-watch-routes.js";
|
|
7
|
+
|
|
8
|
+
const companies: WatchRouteSelection = {
|
|
9
|
+
companies: true,
|
|
10
|
+
company: false,
|
|
11
|
+
personal: false,
|
|
12
|
+
skipPersonal: false,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe("watch route selection", () => {
|
|
16
|
+
it("routes personal and company changes for an ordinary companies run", () => {
|
|
17
|
+
const personal = routeChangeToTarget("knowledge/me.md");
|
|
18
|
+
const company = routeChangeToTarget("companies/indigo/knowledge/team.md");
|
|
19
|
+
|
|
20
|
+
expect(personal).toEqual({ kind: "personal" });
|
|
21
|
+
expect(company).toEqual({ kind: "company", slug: "indigo" });
|
|
22
|
+
expect(isWatchRouteSelected(personal!, companies)).toBe(true);
|
|
23
|
+
expect(isWatchRouteSelected(company!, companies)).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("rejects personal changes before queueing when companies skips personal", () => {
|
|
27
|
+
const personal = routeChangeToTarget("knowledge/me.md");
|
|
28
|
+
|
|
29
|
+
expect(
|
|
30
|
+
isWatchRouteSelected(personal!, { ...companies, skipPersonal: true }),
|
|
31
|
+
).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("keeps route selection scoped for personal and single-company runs", () => {
|
|
35
|
+
const personal = { kind: "personal" } as const;
|
|
36
|
+
const company = { kind: "company", slug: "indigo" } as const;
|
|
37
|
+
|
|
38
|
+
expect(
|
|
39
|
+
isWatchRouteSelected(personal, {
|
|
40
|
+
companies: false,
|
|
41
|
+
company: false,
|
|
42
|
+
personal: true,
|
|
43
|
+
skipPersonal: false,
|
|
44
|
+
}),
|
|
45
|
+
).toBe(true);
|
|
46
|
+
expect(
|
|
47
|
+
isWatchRouteSelected(company, {
|
|
48
|
+
companies: false,
|
|
49
|
+
company: false,
|
|
50
|
+
personal: true,
|
|
51
|
+
skipPersonal: false,
|
|
52
|
+
}),
|
|
53
|
+
).toBe(false);
|
|
54
|
+
expect(
|
|
55
|
+
isWatchRouteSelected(personal, {
|
|
56
|
+
companies: false,
|
|
57
|
+
company: true,
|
|
58
|
+
personal: false,
|
|
59
|
+
skipPersonal: false,
|
|
60
|
+
}),
|
|
61
|
+
).toBe(false);
|
|
62
|
+
expect(
|
|
63
|
+
isWatchRouteSelected(company, {
|
|
64
|
+
companies: false,
|
|
65
|
+
company: true,
|
|
66
|
+
personal: false,
|
|
67
|
+
skipPersonal: false,
|
|
68
|
+
}),
|
|
69
|
+
).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -5,6 +5,18 @@ export type WatchRoute =
|
|
|
5
5
|
| { kind: "company"; slug: string }
|
|
6
6
|
| { kind: "personal" };
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* The sync target selected for a watcher run. Watcher events must be rejected
|
|
10
|
+
* at ingress when they cannot belong to this target; deferring this decision
|
|
11
|
+
* until the pass planner would still queue a needless guarded pass.
|
|
12
|
+
*/
|
|
13
|
+
export interface WatchRouteSelection {
|
|
14
|
+
companies: boolean;
|
|
15
|
+
company: boolean;
|
|
16
|
+
personal: boolean;
|
|
17
|
+
skipPersonal: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
8
20
|
/**
|
|
9
21
|
* Route a changed relative path to the push target that owns it.
|
|
10
22
|
*
|
|
@@ -25,6 +37,21 @@ export function routeChangeToTarget(relPath: string): WatchRoute | null {
|
|
|
25
37
|
return { kind: "personal" };
|
|
26
38
|
}
|
|
27
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Whether a routed watcher change belongs to the command's selected sync
|
|
42
|
+
* target. This is deliberately evaluated before watcher work is queued, so a
|
|
43
|
+
* disabled personal scope cannot create a pass or event-sync publication.
|
|
44
|
+
*/
|
|
45
|
+
export function isWatchRouteSelected(
|
|
46
|
+
route: WatchRoute,
|
|
47
|
+
selection: WatchRouteSelection,
|
|
48
|
+
): boolean {
|
|
49
|
+
if (route.kind === "personal") {
|
|
50
|
+
return selection.personal || (selection.companies && !selection.skipPersonal);
|
|
51
|
+
}
|
|
52
|
+
return selection.companies || selection.company;
|
|
53
|
+
}
|
|
54
|
+
|
|
28
55
|
/**
|
|
29
56
|
* Build the argv for a targeted push pass from a routed change.
|
|
30
57
|
*/
|