@indigoai-us/hq-cloud 5.25.0 → 5.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +34 -0
- package/dist/bin/sync-runner.d.ts +138 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +288 -16
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +372 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- 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 +19 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +13 -0
- package/dist/sync/index.js.map +1 -0
- 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-event.d.ts +110 -0
- package/dist/sync/push-event.d.ts.map +1 -0
- package/dist/sync/push-event.js +153 -0
- package/dist/sync/push-event.js.map +1 -0
- package/dist/sync/push-event.test.d.ts +15 -0
- package/dist/sync/push-event.test.d.ts.map +1 -0
- package/dist/sync/push-event.test.js +188 -0
- package/dist/sync/push-event.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 +150 -0
- package/dist/sync/push-transport.d.ts.map +1 -0
- package/dist/sync/push-transport.js +150 -0
- package/dist/sync/push-transport.js.map +1 -0
- package/dist/watcher.d.ts +271 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +480 -3
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.d.ts +2 -0
- package/dist/watcher.test.d.ts.map +1 -0
- package/dist/watcher.test.js +334 -0
- package/dist/watcher.test.js.map +1 -0
- package/package.json +10 -5
- package/src/bin/sync-runner.test.ts +487 -1
- package/src/bin/sync-runner.ts +406 -9
- package/src/index.ts +38 -0
- package/src/sync/feature-flags.test.ts +392 -0
- package/src/sync/feature-flags.ts +229 -0
- package/src/sync/index.ts +74 -0
- 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-event.test.ts +224 -0
- package/src/sync/push-event.ts +208 -0
- package/src/sync/push-receiver.test.ts +545 -0
- package/src/sync/push-receiver.ts +1077 -0
- package/src/sync/push-transport.ts +231 -0
- package/src/watcher.test.ts +388 -0
- package/src/watcher.ts +672 -4
- package/test/e2e/sync/cross-tenant-isolation.test.ts +502 -0
- package/test/e2e/watcher-real-chokidar.test.ts +105 -0
package/.github/workflows/ci.yml
CHANGED
|
@@ -21,3 +21,37 @@ jobs:
|
|
|
21
21
|
- run: pnpm run build
|
|
22
22
|
- run: pnpm run typecheck
|
|
23
23
|
- run: pnpm test
|
|
24
|
+
|
|
25
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
26
|
+
# US-010 — Cross-tenant isolation invariant (BLOCKING)
|
|
27
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
28
|
+
# Runs the e2e that pins the P0 client-side invariant: NO tenant-A data may
|
|
29
|
+
# ever reach a tenant-B device via the event-driven push OR pull path. The
|
|
30
|
+
# job runs on every pull_request (the workflow trigger above). A FAILURE IS
|
|
31
|
+
# A P0 incident — do not merge/deploy/flag-flip until green again (see the
|
|
32
|
+
# test file's header banner and the project journal).
|
|
33
|
+
#
|
|
34
|
+
# Mirrors hq-pro PR #112's server-side `cross-tenant-isolation` gate on the
|
|
35
|
+
# hq-cloud CLIENT side. Runs in parallel with `build` (no `needs:`) so a
|
|
36
|
+
# security-invariant regression surfaces independent of any unrelated build
|
|
37
|
+
# noise. The test lives under test/ and is run via the project's `test:e2e`
|
|
38
|
+
# script (vitest run test/), narrowed to this one file so the gate is fast
|
|
39
|
+
# and unambiguous.
|
|
40
|
+
cross-tenant-isolation:
|
|
41
|
+
runs-on: ubuntu-latest
|
|
42
|
+
steps:
|
|
43
|
+
- uses: actions/checkout@v4
|
|
44
|
+
- uses: pnpm/action-setup@v4
|
|
45
|
+
with:
|
|
46
|
+
version: 10
|
|
47
|
+
- uses: actions/setup-node@v4
|
|
48
|
+
with:
|
|
49
|
+
node-version: 22
|
|
50
|
+
cache: pnpm
|
|
51
|
+
- run: pnpm install --frozen-lockfile --config.minimumReleaseAge=1440
|
|
52
|
+
# Fail-fast: a broken type surface would make the e2e run produce a
|
|
53
|
+
# confusing runtime error — typecheck up front so this BLOCKING job
|
|
54
|
+
# fails loudly and early instead.
|
|
55
|
+
- run: pnpm run typecheck
|
|
56
|
+
- name: Cross-tenant isolation E2E (blocking)
|
|
57
|
+
run: pnpm vitest run test/e2e/sync/cross-tenant-isolation.test.ts
|
|
@@ -59,6 +59,8 @@ import { type VaultServiceConfig, type Membership, type EntityInfo, type Pending
|
|
|
59
59
|
import { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL, computePersonalVaultPaths } from "../personal-vault.js";
|
|
60
60
|
import type { SyncOptions, SyncResult, SyncProgressEvent } from "../cli/sync.js";
|
|
61
61
|
import type { ShareOptions, ShareResult } from "../cli/share.js";
|
|
62
|
+
import { type Clock } from "../watcher.js";
|
|
63
|
+
import { type PushReceiver, type SyncEngineFn } from "../sync/push-receiver.js";
|
|
62
64
|
/**
|
|
63
65
|
* Sync direction for a run.
|
|
64
66
|
*
|
|
@@ -324,5 +326,140 @@ export declare function runRunner(argv: string[], deps?: RunnerDeps): Promise<nu
|
|
|
324
326
|
* exit 0 today and so will retry — acceptable noise for the beta; deal with
|
|
325
327
|
* it via a richer return shape if it shows up in Sentry.
|
|
326
328
|
*/
|
|
327
|
-
|
|
329
|
+
/**
|
|
330
|
+
* Test/event-driven seam (US-001).
|
|
331
|
+
*
|
|
332
|
+
* `runRunnerWithLoop` performs an unbounded poll loop in production. To make
|
|
333
|
+
* the loop deterministically testable (US-003 wires the event-driven watcher
|
|
334
|
+
* into this same loop), the inter-pass sleep is injectable via `deps.sleep`.
|
|
335
|
+
* The default uses the host timer and preserves exact production behavior.
|
|
336
|
+
*
|
|
337
|
+
* A test (or US-003's wiring) injects a fake sleep that resolves immediately
|
|
338
|
+
* and/or coordinates with the {@link WatchPushDriver} seam in `../watcher.js`,
|
|
339
|
+
* so the loop can be exercised without a real 10-minute wait.
|
|
340
|
+
*/
|
|
341
|
+
export interface RunnerLoopDeps {
|
|
342
|
+
/** Sleep `ms` between passes. Default: host setTimeout. */
|
|
343
|
+
sleep?: (ms: number) => Promise<void>;
|
|
344
|
+
/**
|
|
345
|
+
* Run a single sync pass. Defaults to {@link runRunner}. Injected by tests
|
|
346
|
+
* (and the event-push wiring) so the poll loop and the watcher-triggered
|
|
347
|
+
* targeted push share one seam and one in-flight guard. The default ignores
|
|
348
|
+
* `deps` and forwards just the argv to `runRunner`.
|
|
349
|
+
*/
|
|
350
|
+
runPass?: (passArgv: string[]) => Promise<number>;
|
|
351
|
+
/**
|
|
352
|
+
* Clock seam for the event-push watcher's debounce window. Defaults to
|
|
353
|
+
* {@link systemClock}; tests inject a `FakeClock` to advance the window
|
|
354
|
+
* deterministically. Only consulted when `--event-push` is on.
|
|
355
|
+
*/
|
|
356
|
+
clock?: Clock;
|
|
357
|
+
/**
|
|
358
|
+
* Factory for the file watcher used in event-push mode. Defaults to a real
|
|
359
|
+
* {@link TreeWatcher} over `hqRoot`. Tests inject a stub exposing the same
|
|
360
|
+
* `onChange`/`start`/`stop`/`dispose` surface so no real chokidar runs.
|
|
361
|
+
*/
|
|
362
|
+
createWatcher?: (opts: {
|
|
363
|
+
hqRoot: string;
|
|
364
|
+
debounceMs: number;
|
|
365
|
+
clock: Clock;
|
|
366
|
+
}) => WatcherSurface;
|
|
367
|
+
/**
|
|
368
|
+
* Register a one-shot shutdown signal handler. Defaults to listening for
|
|
369
|
+
* SIGTERM/SIGINT on `process`. Tests inject a controllable trigger to assert
|
|
370
|
+
* clean teardown without sending real signals. The returned fn detaches the
|
|
371
|
+
* handler (called during teardown so tests don't leak listeners).
|
|
372
|
+
*/
|
|
373
|
+
onShutdownSignal?: (handler: () => void) => () => void;
|
|
374
|
+
/**
|
|
375
|
+
* Factory for the Phase 2 pull-on-event receiver (US-009). Defaults to a
|
|
376
|
+
* {@link NoopPushReceiver} — the daemon ships the receiver SEAM wired into
|
|
377
|
+
* the lifecycle (start after the watcher, dispose before exit) but stays
|
|
378
|
+
* DORMANT by default: the per-client SQS queue is provisioned server-side
|
|
379
|
+
* (an unbuilt follow-up) and the receiver is feature-flag gated. A future
|
|
380
|
+
* menubar/CLI release injects an {@link SqsPushReceiver} here once a queue
|
|
381
|
+
* URL is available. Only consulted when `--event-push` is on.
|
|
382
|
+
*
|
|
383
|
+
* The factory is handed a {@link SyncEngineFn} that bridges a received
|
|
384
|
+
* PushEvent to a TARGETED pull pass (`--company <slug> --direction pull`,
|
|
385
|
+
* or a personal `--companies --direction pull`) routed by the event's
|
|
386
|
+
* `relativePath`, funneled through the same in-flight guard as the poll
|
|
387
|
+
* loop and the watcher push so a pull-on-event never overlaps an in-flight
|
|
388
|
+
* pass.
|
|
389
|
+
*/
|
|
390
|
+
createReceiver?: (opts: {
|
|
391
|
+
syncFn: SyncEngineFn;
|
|
392
|
+
hqRoot: string;
|
|
393
|
+
}) => PushReceiver;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* The minimal watcher surface the loop drives. {@link TreeWatcher} satisfies
|
|
397
|
+
* it; tests inject a lighter stub. Kept narrow so the loop never reaches past
|
|
398
|
+
* the lifecycle + change-subscription contract.
|
|
399
|
+
*
|
|
400
|
+
* `onChange`'s listener receives an OPTIONAL changed relative path. The real
|
|
401
|
+
* {@link TreeWatcher} emits a bare debounced signal (no path) — in that case
|
|
402
|
+
* the loop routes the targeted push to the personal vault. A path-aware
|
|
403
|
+
* watcher (or a test stub) can pass the changed `companies/<slug>/...`
|
|
404
|
+
* relative path so the loop targets just that company.
|
|
405
|
+
*/
|
|
406
|
+
export interface WatcherSurface {
|
|
407
|
+
onChange(listener: (changedRelPath?: string) => void): () => void;
|
|
408
|
+
start(): void;
|
|
409
|
+
stop(): void;
|
|
410
|
+
dispose(): void;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Route a changed relative path to the push target that owns it.
|
|
414
|
+
*
|
|
415
|
+
* - `companies/<slug>/...` → a single-company push for `<slug>` (the targeted
|
|
416
|
+
* pass per PRD decision — push only the changed company, not a full
|
|
417
|
+
* `--companies` fanout).
|
|
418
|
+
* - anything else under hqRoot → the personal target (a `--companies` push
|
|
419
|
+
* restricted to personal via the personal-vault scope; modeled here as the
|
|
420
|
+
* "personal" route the caller maps to the right argv).
|
|
421
|
+
*
|
|
422
|
+
* Returns `null` for paths the routing cannot attribute (defensive — the
|
|
423
|
+
* watcher's filter already drops excluded top-levels, so this is belt-and-
|
|
424
|
+
* suspenders for synthetic events).
|
|
425
|
+
*/
|
|
426
|
+
export declare function routeChangeToTarget(relPath: string): {
|
|
427
|
+
kind: "company";
|
|
428
|
+
slug: string;
|
|
429
|
+
} | {
|
|
430
|
+
kind: "personal";
|
|
431
|
+
} | null;
|
|
432
|
+
/**
|
|
433
|
+
* Build the argv for a targeted push pass from a routed change. The push runs
|
|
434
|
+
* `--direction push` for just the routed target so a local edit propagates in
|
|
435
|
+
* seconds without a full fanout. Company routes use `--company <slug>`;
|
|
436
|
+
* personal routes use `--companies --direction push` (the personal-vault scope
|
|
437
|
+
* is resolved inside runRunner's fanout; skipUnchanged no-ops the company
|
|
438
|
+
* subtrees that didn't change). Inherits `--hq-root` / `--on-conflict` from
|
|
439
|
+
* the base argv. Pure helper, exported for unit testing the routing→argv map.
|
|
440
|
+
*/
|
|
441
|
+
export declare function buildTargetedPushArgv(route: {
|
|
442
|
+
kind: "company";
|
|
443
|
+
slug: string;
|
|
444
|
+
} | {
|
|
445
|
+
kind: "personal";
|
|
446
|
+
}, baseArgv: string[]): string[];
|
|
447
|
+
/**
|
|
448
|
+
* Build the argv for a targeted PULL pass from a routed change (US-009 — the
|
|
449
|
+
* receiver's pull-on-event path). Mirrors {@link buildTargetedPushArgv} but
|
|
450
|
+
* with `--direction pull`: a peer device pushed a change, so this device pulls
|
|
451
|
+
* just the affected company/subtree instead of waiting for the next
|
|
452
|
+
* `--poll-remote-ms` cycle. Company routes use `--company <slug>`; personal
|
|
453
|
+
* routes use `--companies` (the personal-vault scope is resolved inside
|
|
454
|
+
* runRunner's fanout; skipUnchanged no-ops the subtrees that didn't change).
|
|
455
|
+
* Inherits `--hq-root` / `--on-conflict` from the base argv. Pure helper,
|
|
456
|
+
* exported for unit testing the event→argv map.
|
|
457
|
+
*/
|
|
458
|
+
export declare function buildTargetedPullArgv(route: {
|
|
459
|
+
kind: "company";
|
|
460
|
+
slug: string;
|
|
461
|
+
} | {
|
|
462
|
+
kind: "personal";
|
|
463
|
+
}, baseArgv: string[]): string[];
|
|
464
|
+
export declare function runRunnerWithLoop(argv: string[], deps?: RunnerLoopDeps): Promise<number>;
|
|
328
465
|
//# sourceMappingURL=sync-runner.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sync-runner.d.ts","sourceRoot":"","sources":["../../src/bin/sync-runner.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AAMH,OAAO,EAOL,KAAK,kBAAkB,EACvB,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,oBAAoB,EAC1B,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,iCAAiC,EACjC,yBAAyB,EAC1B,MAAM,sBAAsB,CAAC;AAE9B,OAAO,KAAK,EACV,WAAW,EACX,UAAU,EACV,iBAAiB,EAClB,MAAM,gBAAgB,CAAC;AAExB,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"sync-runner.d.ts","sourceRoot":"","sources":["../../src/bin/sync-runner.ts"],"names":[],"mappings":"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"}
|
package/dist/bin/sync-runner.js
CHANGED
|
@@ -65,6 +65,8 @@ import { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL, computePersonalVaultPaths, } from ".
|
|
|
65
65
|
import { sync as defaultSync } from "../cli/sync.js";
|
|
66
66
|
import { share as defaultShare } from "../cli/share.js";
|
|
67
67
|
import { collectAndSendTelemetry } from "../telemetry.js";
|
|
68
|
+
import { TreeWatcher, WatchPushDriver, systemClock, } from "../watcher.js";
|
|
69
|
+
import { NoopPushReceiver, } from "../sync/push-receiver.js";
|
|
68
70
|
// ---------------------------------------------------------------------------
|
|
69
71
|
// Defaults — mirror `hq-cli/src/utils/cognito-session.ts`. Inlined (not
|
|
70
72
|
// imported) to avoid a circular dep between hq-cli and hq-cloud. If these
|
|
@@ -190,6 +192,7 @@ function parseArgs(argv) {
|
|
|
190
192
|
let watch = false;
|
|
191
193
|
let pollRemoteMs;
|
|
192
194
|
let skipPersonal = false;
|
|
195
|
+
let eventPush = false;
|
|
193
196
|
for (let i = 0; i < argv.length; i++) {
|
|
194
197
|
const arg = argv[i];
|
|
195
198
|
switch (arg) {
|
|
@@ -251,6 +254,12 @@ function parseArgs(argv) {
|
|
|
251
254
|
// resolveSkipPersonal().
|
|
252
255
|
skipPersonal = true;
|
|
253
256
|
break;
|
|
257
|
+
case "--event-push":
|
|
258
|
+
// Phase 1 event-driven push enable flag. Requires --watch (validated
|
|
259
|
+
// below). Gated OFF by default; the menubar only passes it for
|
|
260
|
+
// @getindigo.ai identities for the first release.
|
|
261
|
+
eventPush = true;
|
|
262
|
+
break;
|
|
254
263
|
default:
|
|
255
264
|
return { error: `Unknown argument: ${arg}` };
|
|
256
265
|
}
|
|
@@ -264,7 +273,20 @@ function parseArgs(argv) {
|
|
|
264
273
|
if (pollRemoteMs !== undefined && !watch) {
|
|
265
274
|
return { error: "--poll-remote-ms requires --watch" };
|
|
266
275
|
}
|
|
267
|
-
|
|
276
|
+
if (eventPush && !watch) {
|
|
277
|
+
return { error: "--event-push requires --watch" };
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
companies,
|
|
281
|
+
company,
|
|
282
|
+
onConflict,
|
|
283
|
+
hqRoot,
|
|
284
|
+
direction,
|
|
285
|
+
watch,
|
|
286
|
+
pollRemoteMs,
|
|
287
|
+
skipPersonal,
|
|
288
|
+
eventPush,
|
|
289
|
+
};
|
|
268
290
|
}
|
|
269
291
|
// ---------------------------------------------------------------------------
|
|
270
292
|
// Telemetry default — closes over the runner's vault client. Skipped when
|
|
@@ -831,37 +853,287 @@ const isDirectInvocation = (() => {
|
|
|
831
853
|
}
|
|
832
854
|
})();
|
|
833
855
|
/**
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
*
|
|
837
|
-
*
|
|
838
|
-
*
|
|
839
|
-
*
|
|
856
|
+
* Route a changed relative path to the push target that owns it.
|
|
857
|
+
*
|
|
858
|
+
* - `companies/<slug>/...` → a single-company push for `<slug>` (the targeted
|
|
859
|
+
* pass per PRD decision — push only the changed company, not a full
|
|
860
|
+
* `--companies` fanout).
|
|
861
|
+
* - anything else under hqRoot → the personal target (a `--companies` push
|
|
862
|
+
* restricted to personal via the personal-vault scope; modeled here as the
|
|
863
|
+
* "personal" route the caller maps to the right argv).
|
|
864
|
+
*
|
|
865
|
+
* Returns `null` for paths the routing cannot attribute (defensive — the
|
|
866
|
+
* watcher's filter already drops excluded top-levels, so this is belt-and-
|
|
867
|
+
* suspenders for synthetic events).
|
|
868
|
+
*/
|
|
869
|
+
export function routeChangeToTarget(relPath) {
|
|
870
|
+
const norm = relPath.split(path.sep).join("/").replace(/^\.\//, "");
|
|
871
|
+
if (norm === "" || norm.startsWith(".."))
|
|
872
|
+
return null;
|
|
873
|
+
const segments = norm.split("/").filter((s) => s.length > 0);
|
|
874
|
+
if (segments.length === 0)
|
|
875
|
+
return null;
|
|
876
|
+
if (segments[0] === "companies") {
|
|
877
|
+
// companies/<slug>/... — need at least the slug segment to target.
|
|
878
|
+
if (segments.length < 2 || segments[1].length === 0)
|
|
879
|
+
return null;
|
|
880
|
+
return { kind: "company", slug: segments[1] };
|
|
881
|
+
}
|
|
882
|
+
return { kind: "personal" };
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Build the argv for a targeted push pass from a routed change. The push runs
|
|
886
|
+
* `--direction push` for just the routed target so a local edit propagates in
|
|
887
|
+
* seconds without a full fanout. Company routes use `--company <slug>`;
|
|
888
|
+
* personal routes use `--companies --direction push` (the personal-vault scope
|
|
889
|
+
* is resolved inside runRunner's fanout; skipUnchanged no-ops the company
|
|
890
|
+
* subtrees that didn't change). Inherits `--hq-root` / `--on-conflict` from
|
|
891
|
+
* the base argv. Pure helper, exported for unit testing the routing→argv map.
|
|
840
892
|
*/
|
|
841
|
-
export
|
|
893
|
+
export function buildTargetedPushArgv(route, baseArgv) {
|
|
894
|
+
const carried = carriedFlags(baseArgv);
|
|
895
|
+
if (route.kind === "company") {
|
|
896
|
+
return ["--company", route.slug, "--direction", "push", ...carried];
|
|
897
|
+
}
|
|
898
|
+
return ["--companies", "--direction", "push", ...carried];
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Build the argv for a targeted PULL pass from a routed change (US-009 — the
|
|
902
|
+
* receiver's pull-on-event path). Mirrors {@link buildTargetedPushArgv} but
|
|
903
|
+
* with `--direction pull`: a peer device pushed a change, so this device pulls
|
|
904
|
+
* just the affected company/subtree instead of waiting for the next
|
|
905
|
+
* `--poll-remote-ms` cycle. Company routes use `--company <slug>`; personal
|
|
906
|
+
* routes use `--companies` (the personal-vault scope is resolved inside
|
|
907
|
+
* runRunner's fanout; skipUnchanged no-ops the subtrees that didn't change).
|
|
908
|
+
* Inherits `--hq-root` / `--on-conflict` from the base argv. Pure helper,
|
|
909
|
+
* exported for unit testing the event→argv map.
|
|
910
|
+
*/
|
|
911
|
+
export function buildTargetedPullArgv(route, baseArgv) {
|
|
912
|
+
const carried = carriedFlags(baseArgv);
|
|
913
|
+
if (route.kind === "company") {
|
|
914
|
+
return ["--company", route.slug, "--direction", "pull", ...carried];
|
|
915
|
+
}
|
|
916
|
+
return ["--companies", "--direction", "pull", ...carried];
|
|
917
|
+
}
|
|
918
|
+
export async function runRunnerWithLoop(argv, deps = {}) {
|
|
842
919
|
if (!argv.includes("--watch")) {
|
|
843
920
|
return runRunner(argv);
|
|
844
921
|
}
|
|
922
|
+
const sleep = deps.sleep ??
|
|
923
|
+
((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
924
|
+
const runPass = deps.runPass ?? ((passArgv) => runRunner(passArgv));
|
|
845
925
|
const pollIdx = argv.indexOf("--poll-remote-ms");
|
|
846
926
|
const pollMs = pollIdx >= 0 && argv[pollIdx + 1] ? Number(argv[pollIdx + 1]) : 600_000;
|
|
847
|
-
|
|
848
|
-
//
|
|
849
|
-
//
|
|
927
|
+
const eventPush = argv.includes("--event-push");
|
|
928
|
+
// In `--companies` mode the sync scope is companies/*/{knowledge,projects,…}
|
|
929
|
+
// (per .hqinclude), so the watcher must NOT apply the personal-vault
|
|
930
|
+
// top-level exclusions (PERSONAL_VAULT_EXCLUDED_TOP_LEVEL drops `companies/`
|
|
931
|
+
// and `workspace/`) — doing so would exclude exactly the paths being synced,
|
|
932
|
+
// and no local edit would ever trigger an instant push. The shared ignore
|
|
933
|
+
// stack (createIgnoreFilter / .hqignore / .hqinclude) already scopes the
|
|
934
|
+
// watch filter correctly in companies mode. personalMode is only for a
|
|
935
|
+
// personal-vault-as-root run, where companies/ et al. genuinely aren't synced.
|
|
936
|
+
const companiesMode = argv.includes("--companies");
|
|
937
|
+
const hqIdx = argv.indexOf("--hq-root");
|
|
938
|
+
const hqRoot = hqIdx >= 0 && argv[hqIdx + 1] ? argv[hqIdx + 1] : DEFAULT_HQ_ROOT;
|
|
939
|
+
// Strip the loop-only flags before delegating: the parser inside runRunner
|
|
940
|
+
// accepts --watch/--poll-remote-ms/--event-push, but we don't want a per-
|
|
941
|
+
// iteration pass to think it's re-entering watch mode.
|
|
850
942
|
const passArgv = argv.filter((a, i) => {
|
|
851
943
|
if (a === "--watch")
|
|
852
944
|
return false;
|
|
853
945
|
if (a === "--poll-remote-ms")
|
|
854
946
|
return false;
|
|
947
|
+
if (a === "--event-push")
|
|
948
|
+
return false;
|
|
855
949
|
if (i > 0 && argv[i - 1] === "--poll-remote-ms")
|
|
856
950
|
return false;
|
|
857
951
|
return true;
|
|
858
952
|
});
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
953
|
+
// ---- shared in-flight guard ------------------------------------------
|
|
954
|
+
// The poll loop AND watcher-triggered targeted pushes funnel through this
|
|
955
|
+
// mutex so a watcher push never overlaps an in-flight pass (PRD AC). A
|
|
956
|
+
// trigger that arrives while a pass runs is collapsed by WatchPushDriver's
|
|
957
|
+
// own pending-while-pushing logic, then re-armed after the pass settles.
|
|
958
|
+
let inFlight = false;
|
|
959
|
+
let stopped = false;
|
|
960
|
+
const runGuarded = async (pass) => {
|
|
961
|
+
if (inFlight)
|
|
962
|
+
return "skipped";
|
|
963
|
+
inFlight = true;
|
|
964
|
+
try {
|
|
965
|
+
return await pass();
|
|
966
|
+
}
|
|
967
|
+
finally {
|
|
968
|
+
inFlight = false;
|
|
969
|
+
}
|
|
970
|
+
};
|
|
971
|
+
// ---- event-push wiring (Phase 1) -------------------------------------
|
|
972
|
+
let watcher = null;
|
|
973
|
+
let driver = null;
|
|
974
|
+
let detachSignal = null;
|
|
975
|
+
let lastChangedRel = null;
|
|
976
|
+
// ---- pull-on-event receiver (Phase 2, US-009) ------------------------
|
|
977
|
+
// Started after the watcher, disposed before the watcher (mirror of the
|
|
978
|
+
// PushTransport ordering). Dormant by default: the default factory returns
|
|
979
|
+
// a NoopPushReceiver, and even a real receiver stays dormant unless the
|
|
980
|
+
// per-tenant feature flag is on AND a queue URL is provisioned server-side.
|
|
981
|
+
let receiver = null;
|
|
982
|
+
if (eventPush) {
|
|
983
|
+
const clock = deps.clock ?? systemClock;
|
|
984
|
+
const debounceMs = 2000;
|
|
985
|
+
const createWatcher = deps.createWatcher ??
|
|
986
|
+
((opts) => new TreeWatcher({
|
|
987
|
+
hqRoot: opts.hqRoot,
|
|
988
|
+
debounceMs: opts.debounceMs,
|
|
989
|
+
clock: opts.clock,
|
|
990
|
+
// false in --companies mode so the watch filter matches the sync
|
|
991
|
+
// scope (companies/* are included via .hqinclude); true only for a
|
|
992
|
+
// personal-vault-as-root run.
|
|
993
|
+
personalMode: !companiesMode,
|
|
994
|
+
}));
|
|
995
|
+
watcher = createWatcher({ hqRoot, debounceMs, clock });
|
|
996
|
+
// The driver runs the targeted push when a debounced burst settles. Its
|
|
997
|
+
// concurrency guard collapses a trigger that lands mid-pass; we ALSO gate
|
|
998
|
+
// through `runGuarded` so a poll pass in flight is never overlapped.
|
|
999
|
+
driver = new WatchPushDriver({
|
|
1000
|
+
debounceMs: 0, // TreeWatcher already debounces; the driver just guards.
|
|
1001
|
+
clock,
|
|
1002
|
+
push: async () => {
|
|
1003
|
+
if (stopped)
|
|
1004
|
+
return;
|
|
1005
|
+
const rel = lastChangedRel;
|
|
1006
|
+
const route = rel
|
|
1007
|
+
? routeChangeToTarget(rel)
|
|
1008
|
+
: { kind: "personal" };
|
|
1009
|
+
if (!route)
|
|
1010
|
+
return;
|
|
1011
|
+
const targetedArgv = buildTargetedPushArgv(route, passArgv);
|
|
1012
|
+
await runGuarded(() => runPass(targetedArgv));
|
|
1013
|
+
},
|
|
1014
|
+
});
|
|
1015
|
+
// A debounced TreeWatcher 'changed' signal feeds the driver, which fires
|
|
1016
|
+
// the targeted push after its own (zero) window — i.e. immediately, but
|
|
1017
|
+
// still serialized behind any in-flight pass. A path-aware watcher passes
|
|
1018
|
+
// the changed relative path so the push targets just its owning company;
|
|
1019
|
+
// the bare-signal TreeWatcher leaves it null → personal-vault route.
|
|
1020
|
+
watcher.onChange((changedRelPath) => {
|
|
1021
|
+
if (stopped)
|
|
1022
|
+
return;
|
|
1023
|
+
lastChangedRel = changedRelPath ?? null;
|
|
1024
|
+
driver?.notifyChange();
|
|
1025
|
+
});
|
|
1026
|
+
watcher.start();
|
|
1027
|
+
// Pull-on-event receiver (US-009). The injected SyncEngineFn bridges a
|
|
1028
|
+
// received PushEvent → a TARGETED pull pass routed by relativePath, funneled
|
|
1029
|
+
// through the SAME `runGuarded` mutex as the poll loop + watcher push so a
|
|
1030
|
+
// pull-on-event never overlaps an in-flight pass. Started AFTER the watcher
|
|
1031
|
+
// so a live event can't race a half-built daemon. Default factory = noop
|
|
1032
|
+
// (dormant); a real SqsPushReceiver is injected by a later release once the
|
|
1033
|
+
// server-side per-client SQS queue is provisioned.
|
|
1034
|
+
const receiverSyncFn = async (ctx) => {
|
|
1035
|
+
if (stopped)
|
|
1036
|
+
return;
|
|
1037
|
+
const route = routeChangeToTarget(ctx.event.relativePath);
|
|
1038
|
+
if (!route)
|
|
1039
|
+
return;
|
|
1040
|
+
const targetedArgv = buildTargetedPullArgv(route, passArgv);
|
|
1041
|
+
await runGuarded(() => runPass(targetedArgv));
|
|
1042
|
+
};
|
|
1043
|
+
const createReceiver = deps.createReceiver ?? (() => new NoopPushReceiver());
|
|
1044
|
+
receiver = createReceiver({ syncFn: receiverSyncFn, hqRoot });
|
|
1045
|
+
// Fire-and-forget start: a receiver's start() kicks off its own poll loop
|
|
1046
|
+
// (SqsPushReceiver) or trivially flips connected (noop) — it must NOT block
|
|
1047
|
+
// the runner's poll loop from entering. Errors are swallowed; the cadence
|
|
1048
|
+
// poll is the safety net regardless of receiver health. (The await-free
|
|
1049
|
+
// start also keeps the poll loop's microtask timing identical to the
|
|
1050
|
+
// pre-US-009 wiring.)
|
|
1051
|
+
void Promise.resolve(receiver.start()).catch(() => undefined);
|
|
1052
|
+
}
|
|
1053
|
+
// ---- clean shutdown --------------------------------------------------
|
|
1054
|
+
// SIGTERM (menubar stop_daemon) / SIGINT must tear down BOTH the watcher and
|
|
1055
|
+
// the poll loop with no leaked timers or fds. We flip `stopped`, dispose the
|
|
1056
|
+
// watcher + driver, and let the poll loop observe `stopped` on its next tick.
|
|
1057
|
+
let resolveStopped = null;
|
|
1058
|
+
const stoppedSignal = new Promise((resolve) => {
|
|
1059
|
+
resolveStopped = resolve;
|
|
1060
|
+
});
|
|
1061
|
+
const shutdown = () => {
|
|
1062
|
+
if (stopped)
|
|
1063
|
+
return;
|
|
1064
|
+
stopped = true;
|
|
1065
|
+
// Dispose the receiver FIRST (mirror of the PushTransport ordering:
|
|
1066
|
+
// inbound subscription torn down before the watcher) so no new
|
|
1067
|
+
// pull-on-event fires mid-teardown. dispose() is async (it drains the
|
|
1068
|
+
// in-flight pull up to its own deadline); fire-and-forget here — the
|
|
1069
|
+
// receiver's internal drain + the runGuarded mutex bound the work, and
|
|
1070
|
+
// SIGTERM teardown must not block. Errors are swallowed.
|
|
1071
|
+
try {
|
|
1072
|
+
void receiver?.dispose();
|
|
1073
|
+
}
|
|
1074
|
+
catch {
|
|
1075
|
+
/* ignore */
|
|
1076
|
+
}
|
|
1077
|
+
try {
|
|
1078
|
+
driver?.dispose();
|
|
1079
|
+
}
|
|
1080
|
+
catch {
|
|
1081
|
+
/* ignore */
|
|
1082
|
+
}
|
|
1083
|
+
try {
|
|
1084
|
+
watcher?.dispose();
|
|
1085
|
+
}
|
|
1086
|
+
catch {
|
|
1087
|
+
/* ignore */
|
|
1088
|
+
}
|
|
1089
|
+
resolveStopped?.();
|
|
1090
|
+
};
|
|
1091
|
+
const onShutdownSignal = deps.onShutdownSignal ??
|
|
1092
|
+
((handler) => {
|
|
1093
|
+
const wrapped = () => handler();
|
|
1094
|
+
process.on("SIGTERM", wrapped);
|
|
1095
|
+
process.on("SIGINT", wrapped);
|
|
1096
|
+
return () => {
|
|
1097
|
+
process.off("SIGTERM", wrapped);
|
|
1098
|
+
process.off("SIGINT", wrapped);
|
|
1099
|
+
};
|
|
1100
|
+
});
|
|
1101
|
+
detachSignal = onShutdownSignal(shutdown);
|
|
1102
|
+
try {
|
|
1103
|
+
while (!stopped) {
|
|
1104
|
+
const result = await runGuarded(() => runPass(passArgv));
|
|
1105
|
+
// A poll pass that was skipped because a watcher push held the guard is
|
|
1106
|
+
// benign — the next iteration retries after the poll interval.
|
|
1107
|
+
if (typeof result === "number" && result !== 0) {
|
|
1108
|
+
return result;
|
|
1109
|
+
}
|
|
1110
|
+
// Sleep the poll interval, but wake early on shutdown so SIGTERM stops
|
|
1111
|
+
// the loop promptly instead of waiting out a 10-minute cycle.
|
|
1112
|
+
await Promise.race([sleep(pollMs), stoppedSignal]);
|
|
1113
|
+
}
|
|
1114
|
+
return 0;
|
|
1115
|
+
}
|
|
1116
|
+
finally {
|
|
1117
|
+
shutdown();
|
|
1118
|
+
detachSignal?.();
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Extract the `--hq-root` / `--on-conflict` pair-flags from a base argv so a
|
|
1123
|
+
* re-targeted push pass inherits the same root and conflict policy. Pure
|
|
1124
|
+
* helper used by the event-push targeted-push composer.
|
|
1125
|
+
*/
|
|
1126
|
+
function carriedFlags(baseArgv) {
|
|
1127
|
+
const carried = [];
|
|
1128
|
+
for (let i = 0; i < baseArgv.length; i++) {
|
|
1129
|
+
const a = baseArgv[i];
|
|
1130
|
+
if (a === "--hq-root" || a === "--on-conflict") {
|
|
1131
|
+
carried.push(a);
|
|
1132
|
+
if (baseArgv[i + 1] !== undefined)
|
|
1133
|
+
carried.push(baseArgv[++i]);
|
|
1134
|
+
}
|
|
864
1135
|
}
|
|
1136
|
+
return carried;
|
|
865
1137
|
}
|
|
866
1138
|
if (isDirectInvocation) {
|
|
867
1139
|
runRunnerWithLoop(process.argv.slice(2))
|