@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/src/bin/sync-runner.ts
CHANGED
|
@@ -88,6 +88,17 @@ import type { ShareOptions, ShareResult } from "../cli/share.js";
|
|
|
88
88
|
import type { ConflictStrategy } from "../cli/conflict.js";
|
|
89
89
|
import type { UploadAuthor } from "../s3.js";
|
|
90
90
|
import { collectAndSendTelemetry } from "../telemetry.js";
|
|
91
|
+
import {
|
|
92
|
+
TreeWatcher,
|
|
93
|
+
WatchPushDriver,
|
|
94
|
+
systemClock,
|
|
95
|
+
type Clock,
|
|
96
|
+
} from "../watcher.js";
|
|
97
|
+
import {
|
|
98
|
+
NoopPushReceiver,
|
|
99
|
+
type PushReceiver,
|
|
100
|
+
type SyncEngineFn,
|
|
101
|
+
} from "../sync/push-receiver.js";
|
|
91
102
|
|
|
92
103
|
/**
|
|
93
104
|
* Sync direction for a run.
|
|
@@ -442,6 +453,15 @@ interface ParsedArgs {
|
|
|
442
453
|
watch: boolean;
|
|
443
454
|
/** Auto-sync (Beta): ms between remote-pull passes. Required when watch=true. */
|
|
444
455
|
pollRemoteMs?: number;
|
|
456
|
+
/**
|
|
457
|
+
* Event-driven push (Phase 1). When set (and `--watch` is on), the runner
|
|
458
|
+
* starts a {@link TreeWatcher} alongside the poll loop and pushes a targeted
|
|
459
|
+
* company/subtree within the debounce window of a local edit — instead of
|
|
460
|
+
* waiting up to a full `--poll-remote-ms` cycle. Gated OFF by default; the
|
|
461
|
+
* menubar passes it only for `@getindigo.ai` identities (see PRD decision).
|
|
462
|
+
* No-op without `--watch` (the one-shot path has nothing to keep alive).
|
|
463
|
+
*/
|
|
464
|
+
eventPush: boolean;
|
|
445
465
|
/**
|
|
446
466
|
* Drop the personal target from the fanout. Combined with the
|
|
447
467
|
* `HQ_SYNC_SKIP_PERSONAL` env var by `resolveSkipPersonal()` — either
|
|
@@ -460,6 +480,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
460
480
|
let watch = false;
|
|
461
481
|
let pollRemoteMs: number | undefined;
|
|
462
482
|
let skipPersonal = false;
|
|
483
|
+
let eventPush = false;
|
|
463
484
|
|
|
464
485
|
for (let i = 0; i < argv.length; i++) {
|
|
465
486
|
const arg = argv[i];
|
|
@@ -519,6 +540,12 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
519
540
|
// resolveSkipPersonal().
|
|
520
541
|
skipPersonal = true;
|
|
521
542
|
break;
|
|
543
|
+
case "--event-push":
|
|
544
|
+
// Phase 1 event-driven push enable flag. Requires --watch (validated
|
|
545
|
+
// below). Gated OFF by default; the menubar only passes it for
|
|
546
|
+
// @getindigo.ai identities for the first release.
|
|
547
|
+
eventPush = true;
|
|
548
|
+
break;
|
|
522
549
|
default:
|
|
523
550
|
return { error: `Unknown argument: ${arg}` };
|
|
524
551
|
}
|
|
@@ -533,8 +560,21 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
533
560
|
if (pollRemoteMs !== undefined && !watch) {
|
|
534
561
|
return { error: "--poll-remote-ms requires --watch" };
|
|
535
562
|
}
|
|
563
|
+
if (eventPush && !watch) {
|
|
564
|
+
return { error: "--event-push requires --watch" };
|
|
565
|
+
}
|
|
536
566
|
|
|
537
|
-
return {
|
|
567
|
+
return {
|
|
568
|
+
companies,
|
|
569
|
+
company,
|
|
570
|
+
onConflict,
|
|
571
|
+
hqRoot,
|
|
572
|
+
direction,
|
|
573
|
+
watch,
|
|
574
|
+
pollRemoteMs,
|
|
575
|
+
skipPersonal,
|
|
576
|
+
eventPush,
|
|
577
|
+
};
|
|
538
578
|
}
|
|
539
579
|
|
|
540
580
|
// ---------------------------------------------------------------------------
|
|
@@ -1169,29 +1209,386 @@ const isDirectInvocation = (() => {
|
|
|
1169
1209
|
* exit 0 today and so will retry — acceptable noise for the beta; deal with
|
|
1170
1210
|
* it via a richer return shape if it shows up in Sentry.
|
|
1171
1211
|
*/
|
|
1172
|
-
|
|
1212
|
+
/**
|
|
1213
|
+
* Test/event-driven seam (US-001).
|
|
1214
|
+
*
|
|
1215
|
+
* `runRunnerWithLoop` performs an unbounded poll loop in production. To make
|
|
1216
|
+
* the loop deterministically testable (US-003 wires the event-driven watcher
|
|
1217
|
+
* into this same loop), the inter-pass sleep is injectable via `deps.sleep`.
|
|
1218
|
+
* The default uses the host timer and preserves exact production behavior.
|
|
1219
|
+
*
|
|
1220
|
+
* A test (or US-003's wiring) injects a fake sleep that resolves immediately
|
|
1221
|
+
* and/or coordinates with the {@link WatchPushDriver} seam in `../watcher.js`,
|
|
1222
|
+
* so the loop can be exercised without a real 10-minute wait.
|
|
1223
|
+
*/
|
|
1224
|
+
export interface RunnerLoopDeps {
|
|
1225
|
+
/** Sleep `ms` between passes. Default: host setTimeout. */
|
|
1226
|
+
sleep?: (ms: number) => Promise<void>;
|
|
1227
|
+
/**
|
|
1228
|
+
* Run a single sync pass. Defaults to {@link runRunner}. Injected by tests
|
|
1229
|
+
* (and the event-push wiring) so the poll loop and the watcher-triggered
|
|
1230
|
+
* targeted push share one seam and one in-flight guard. The default ignores
|
|
1231
|
+
* `deps` and forwards just the argv to `runRunner`.
|
|
1232
|
+
*/
|
|
1233
|
+
runPass?: (passArgv: string[]) => Promise<number>;
|
|
1234
|
+
/**
|
|
1235
|
+
* Clock seam for the event-push watcher's debounce window. Defaults to
|
|
1236
|
+
* {@link systemClock}; tests inject a `FakeClock` to advance the window
|
|
1237
|
+
* deterministically. Only consulted when `--event-push` is on.
|
|
1238
|
+
*/
|
|
1239
|
+
clock?: Clock;
|
|
1240
|
+
/**
|
|
1241
|
+
* Factory for the file watcher used in event-push mode. Defaults to a real
|
|
1242
|
+
* {@link TreeWatcher} over `hqRoot`. Tests inject a stub exposing the same
|
|
1243
|
+
* `onChange`/`start`/`stop`/`dispose` surface so no real chokidar runs.
|
|
1244
|
+
*/
|
|
1245
|
+
createWatcher?: (opts: {
|
|
1246
|
+
hqRoot: string;
|
|
1247
|
+
debounceMs: number;
|
|
1248
|
+
clock: Clock;
|
|
1249
|
+
}) => WatcherSurface;
|
|
1250
|
+
/**
|
|
1251
|
+
* Register a one-shot shutdown signal handler. Defaults to listening for
|
|
1252
|
+
* SIGTERM/SIGINT on `process`. Tests inject a controllable trigger to assert
|
|
1253
|
+
* clean teardown without sending real signals. The returned fn detaches the
|
|
1254
|
+
* handler (called during teardown so tests don't leak listeners).
|
|
1255
|
+
*/
|
|
1256
|
+
onShutdownSignal?: (handler: () => void) => () => void;
|
|
1257
|
+
/**
|
|
1258
|
+
* Factory for the Phase 2 pull-on-event receiver (US-009). Defaults to a
|
|
1259
|
+
* {@link NoopPushReceiver} — the daemon ships the receiver SEAM wired into
|
|
1260
|
+
* the lifecycle (start after the watcher, dispose before exit) but stays
|
|
1261
|
+
* DORMANT by default: the per-client SQS queue is provisioned server-side
|
|
1262
|
+
* (an unbuilt follow-up) and the receiver is feature-flag gated. A future
|
|
1263
|
+
* menubar/CLI release injects an {@link SqsPushReceiver} here once a queue
|
|
1264
|
+
* URL is available. Only consulted when `--event-push` is on.
|
|
1265
|
+
*
|
|
1266
|
+
* The factory is handed a {@link SyncEngineFn} that bridges a received
|
|
1267
|
+
* PushEvent to a TARGETED pull pass (`--company <slug> --direction pull`,
|
|
1268
|
+
* or a personal `--companies --direction pull`) routed by the event's
|
|
1269
|
+
* `relativePath`, funneled through the same in-flight guard as the poll
|
|
1270
|
+
* loop and the watcher push so a pull-on-event never overlaps an in-flight
|
|
1271
|
+
* pass.
|
|
1272
|
+
*/
|
|
1273
|
+
createReceiver?: (opts: {
|
|
1274
|
+
syncFn: SyncEngineFn;
|
|
1275
|
+
hqRoot: string;
|
|
1276
|
+
}) => PushReceiver;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* The minimal watcher surface the loop drives. {@link TreeWatcher} satisfies
|
|
1281
|
+
* it; tests inject a lighter stub. Kept narrow so the loop never reaches past
|
|
1282
|
+
* the lifecycle + change-subscription contract.
|
|
1283
|
+
*
|
|
1284
|
+
* `onChange`'s listener receives an OPTIONAL changed relative path. The real
|
|
1285
|
+
* {@link TreeWatcher} emits a bare debounced signal (no path) — in that case
|
|
1286
|
+
* the loop routes the targeted push to the personal vault. A path-aware
|
|
1287
|
+
* watcher (or a test stub) can pass the changed `companies/<slug>/...`
|
|
1288
|
+
* relative path so the loop targets just that company.
|
|
1289
|
+
*/
|
|
1290
|
+
export interface WatcherSurface {
|
|
1291
|
+
onChange(listener: (changedRelPath?: string) => void): () => void;
|
|
1292
|
+
start(): void;
|
|
1293
|
+
stop(): void;
|
|
1294
|
+
dispose(): void;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* Route a changed relative path to the push target that owns it.
|
|
1299
|
+
*
|
|
1300
|
+
* - `companies/<slug>/...` → a single-company push for `<slug>` (the targeted
|
|
1301
|
+
* pass per PRD decision — push only the changed company, not a full
|
|
1302
|
+
* `--companies` fanout).
|
|
1303
|
+
* - anything else under hqRoot → the personal target (a `--companies` push
|
|
1304
|
+
* restricted to personal via the personal-vault scope; modeled here as the
|
|
1305
|
+
* "personal" route the caller maps to the right argv).
|
|
1306
|
+
*
|
|
1307
|
+
* Returns `null` for paths the routing cannot attribute (defensive — the
|
|
1308
|
+
* watcher's filter already drops excluded top-levels, so this is belt-and-
|
|
1309
|
+
* suspenders for synthetic events).
|
|
1310
|
+
*/
|
|
1311
|
+
export function routeChangeToTarget(
|
|
1312
|
+
relPath: string,
|
|
1313
|
+
): { kind: "company"; slug: string } | { kind: "personal" } | null {
|
|
1314
|
+
const norm = relPath.split(path.sep).join("/").replace(/^\.\//, "");
|
|
1315
|
+
if (norm === "" || norm.startsWith("..")) return null;
|
|
1316
|
+
const segments = norm.split("/").filter((s) => s.length > 0);
|
|
1317
|
+
if (segments.length === 0) return null;
|
|
1318
|
+
if (segments[0] === "companies") {
|
|
1319
|
+
// companies/<slug>/... — need at least the slug segment to target.
|
|
1320
|
+
if (segments.length < 2 || segments[1].length === 0) return null;
|
|
1321
|
+
return { kind: "company", slug: segments[1] };
|
|
1322
|
+
}
|
|
1323
|
+
return { kind: "personal" };
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/**
|
|
1327
|
+
* Build the argv for a targeted push pass from a routed change. The push runs
|
|
1328
|
+
* `--direction push` for just the routed target so a local edit propagates in
|
|
1329
|
+
* seconds without a full fanout. Company routes use `--company <slug>`;
|
|
1330
|
+
* personal routes use `--companies --direction push` (the personal-vault scope
|
|
1331
|
+
* is resolved inside runRunner's fanout; skipUnchanged no-ops the company
|
|
1332
|
+
* subtrees that didn't change). Inherits `--hq-root` / `--on-conflict` from
|
|
1333
|
+
* the base argv. Pure helper, exported for unit testing the routing→argv map.
|
|
1334
|
+
*/
|
|
1335
|
+
export function buildTargetedPushArgv(
|
|
1336
|
+
route: { kind: "company"; slug: string } | { kind: "personal" },
|
|
1337
|
+
baseArgv: string[],
|
|
1338
|
+
): string[] {
|
|
1339
|
+
const carried = carriedFlags(baseArgv);
|
|
1340
|
+
if (route.kind === "company") {
|
|
1341
|
+
return ["--company", route.slug, "--direction", "push", ...carried];
|
|
1342
|
+
}
|
|
1343
|
+
return ["--companies", "--direction", "push", ...carried];
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* Build the argv for a targeted PULL pass from a routed change (US-009 — the
|
|
1348
|
+
* receiver's pull-on-event path). Mirrors {@link buildTargetedPushArgv} but
|
|
1349
|
+
* with `--direction pull`: a peer device pushed a change, so this device pulls
|
|
1350
|
+
* just the affected company/subtree instead of waiting for the next
|
|
1351
|
+
* `--poll-remote-ms` cycle. Company routes use `--company <slug>`; personal
|
|
1352
|
+
* routes use `--companies` (the personal-vault scope is resolved inside
|
|
1353
|
+
* runRunner's fanout; skipUnchanged no-ops the subtrees that didn't change).
|
|
1354
|
+
* Inherits `--hq-root` / `--on-conflict` from the base argv. Pure helper,
|
|
1355
|
+
* exported for unit testing the event→argv map.
|
|
1356
|
+
*/
|
|
1357
|
+
export function buildTargetedPullArgv(
|
|
1358
|
+
route: { kind: "company"; slug: string } | { kind: "personal" },
|
|
1359
|
+
baseArgv: string[],
|
|
1360
|
+
): string[] {
|
|
1361
|
+
const carried = carriedFlags(baseArgv);
|
|
1362
|
+
if (route.kind === "company") {
|
|
1363
|
+
return ["--company", route.slug, "--direction", "pull", ...carried];
|
|
1364
|
+
}
|
|
1365
|
+
return ["--companies", "--direction", "pull", ...carried];
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
export async function runRunnerWithLoop(
|
|
1369
|
+
argv: string[],
|
|
1370
|
+
deps: RunnerLoopDeps = {},
|
|
1371
|
+
): Promise<number> {
|
|
1173
1372
|
if (!argv.includes("--watch")) {
|
|
1174
1373
|
return runRunner(argv);
|
|
1175
1374
|
}
|
|
1375
|
+
const sleep =
|
|
1376
|
+
deps.sleep ??
|
|
1377
|
+
((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
1378
|
+
const runPass = deps.runPass ?? ((passArgv: string[]) => runRunner(passArgv));
|
|
1176
1379
|
const pollIdx = argv.indexOf("--poll-remote-ms");
|
|
1177
1380
|
const pollMs =
|
|
1178
1381
|
pollIdx >= 0 && argv[pollIdx + 1] ? Number(argv[pollIdx + 1]) : 600_000;
|
|
1382
|
+
const eventPush = argv.includes("--event-push");
|
|
1383
|
+
// In `--companies` mode the sync scope is companies/*/{knowledge,projects,…}
|
|
1384
|
+
// (per .hqinclude), so the watcher must NOT apply the personal-vault
|
|
1385
|
+
// top-level exclusions (PERSONAL_VAULT_EXCLUDED_TOP_LEVEL drops `companies/`
|
|
1386
|
+
// and `workspace/`) — doing so would exclude exactly the paths being synced,
|
|
1387
|
+
// and no local edit would ever trigger an instant push. The shared ignore
|
|
1388
|
+
// stack (createIgnoreFilter / .hqignore / .hqinclude) already scopes the
|
|
1389
|
+
// watch filter correctly in companies mode. personalMode is only for a
|
|
1390
|
+
// personal-vault-as-root run, where companies/ et al. genuinely aren't synced.
|
|
1391
|
+
const companiesMode = argv.includes("--companies");
|
|
1392
|
+
const hqIdx = argv.indexOf("--hq-root");
|
|
1393
|
+
const hqRoot =
|
|
1394
|
+
hqIdx >= 0 && argv[hqIdx + 1] ? argv[hqIdx + 1] : DEFAULT_HQ_ROOT;
|
|
1179
1395
|
|
|
1180
|
-
// Strip
|
|
1181
|
-
//
|
|
1182
|
-
// re-entering watch mode
|
|
1396
|
+
// Strip the loop-only flags before delegating: the parser inside runRunner
|
|
1397
|
+
// accepts --watch/--poll-remote-ms/--event-push, but we don't want a per-
|
|
1398
|
+
// iteration pass to think it's re-entering watch mode.
|
|
1183
1399
|
const passArgv = argv.filter((a, i) => {
|
|
1184
1400
|
if (a === "--watch") return false;
|
|
1185
1401
|
if (a === "--poll-remote-ms") return false;
|
|
1402
|
+
if (a === "--event-push") return false;
|
|
1186
1403
|
if (i > 0 && argv[i - 1] === "--poll-remote-ms") return false;
|
|
1187
1404
|
return true;
|
|
1188
1405
|
});
|
|
1189
1406
|
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1407
|
+
// ---- shared in-flight guard ------------------------------------------
|
|
1408
|
+
// The poll loop AND watcher-triggered targeted pushes funnel through this
|
|
1409
|
+
// mutex so a watcher push never overlaps an in-flight pass (PRD AC). A
|
|
1410
|
+
// trigger that arrives while a pass runs is collapsed by WatchPushDriver's
|
|
1411
|
+
// own pending-while-pushing logic, then re-armed after the pass settles.
|
|
1412
|
+
let inFlight = false;
|
|
1413
|
+
let stopped = false;
|
|
1414
|
+
const runGuarded = async (
|
|
1415
|
+
pass: () => Promise<number>,
|
|
1416
|
+
): Promise<number | "skipped"> => {
|
|
1417
|
+
if (inFlight) return "skipped";
|
|
1418
|
+
inFlight = true;
|
|
1419
|
+
try {
|
|
1420
|
+
return await pass();
|
|
1421
|
+
} finally {
|
|
1422
|
+
inFlight = false;
|
|
1423
|
+
}
|
|
1424
|
+
};
|
|
1425
|
+
|
|
1426
|
+
// ---- event-push wiring (Phase 1) -------------------------------------
|
|
1427
|
+
let watcher: WatcherSurface | null = null;
|
|
1428
|
+
let driver: WatchPushDriver | null = null;
|
|
1429
|
+
let detachSignal: (() => void) | null = null;
|
|
1430
|
+
let lastChangedRel: string | null = null;
|
|
1431
|
+
// ---- pull-on-event receiver (Phase 2, US-009) ------------------------
|
|
1432
|
+
// Started after the watcher, disposed before the watcher (mirror of the
|
|
1433
|
+
// PushTransport ordering). Dormant by default: the default factory returns
|
|
1434
|
+
// a NoopPushReceiver, and even a real receiver stays dormant unless the
|
|
1435
|
+
// per-tenant feature flag is on AND a queue URL is provisioned server-side.
|
|
1436
|
+
let receiver: PushReceiver | null = null;
|
|
1437
|
+
|
|
1438
|
+
if (eventPush) {
|
|
1439
|
+
const clock = deps.clock ?? systemClock;
|
|
1440
|
+
const debounceMs = 2000;
|
|
1441
|
+
const createWatcher =
|
|
1442
|
+
deps.createWatcher ??
|
|
1443
|
+
((opts) =>
|
|
1444
|
+
new TreeWatcher({
|
|
1445
|
+
hqRoot: opts.hqRoot,
|
|
1446
|
+
debounceMs: opts.debounceMs,
|
|
1447
|
+
clock: opts.clock,
|
|
1448
|
+
// false in --companies mode so the watch filter matches the sync
|
|
1449
|
+
// scope (companies/* are included via .hqinclude); true only for a
|
|
1450
|
+
// personal-vault-as-root run.
|
|
1451
|
+
personalMode: !companiesMode,
|
|
1452
|
+
}));
|
|
1453
|
+
watcher = createWatcher({ hqRoot, debounceMs, clock });
|
|
1454
|
+
|
|
1455
|
+
// The driver runs the targeted push when a debounced burst settles. Its
|
|
1456
|
+
// concurrency guard collapses a trigger that lands mid-pass; we ALSO gate
|
|
1457
|
+
// through `runGuarded` so a poll pass in flight is never overlapped.
|
|
1458
|
+
driver = new WatchPushDriver({
|
|
1459
|
+
debounceMs: 0, // TreeWatcher already debounces; the driver just guards.
|
|
1460
|
+
clock,
|
|
1461
|
+
push: async () => {
|
|
1462
|
+
if (stopped) return;
|
|
1463
|
+
const rel = lastChangedRel;
|
|
1464
|
+
const route = rel
|
|
1465
|
+
? routeChangeToTarget(rel)
|
|
1466
|
+
: { kind: "personal" as const };
|
|
1467
|
+
if (!route) return;
|
|
1468
|
+
const targetedArgv = buildTargetedPushArgv(route, passArgv);
|
|
1469
|
+
await runGuarded(() => runPass(targetedArgv));
|
|
1470
|
+
},
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
// A debounced TreeWatcher 'changed' signal feeds the driver, which fires
|
|
1474
|
+
// the targeted push after its own (zero) window — i.e. immediately, but
|
|
1475
|
+
// still serialized behind any in-flight pass. A path-aware watcher passes
|
|
1476
|
+
// the changed relative path so the push targets just its owning company;
|
|
1477
|
+
// the bare-signal TreeWatcher leaves it null → personal-vault route.
|
|
1478
|
+
watcher.onChange((changedRelPath) => {
|
|
1479
|
+
if (stopped) return;
|
|
1480
|
+
lastChangedRel = changedRelPath ?? null;
|
|
1481
|
+
driver?.notifyChange();
|
|
1482
|
+
});
|
|
1483
|
+
watcher.start();
|
|
1484
|
+
|
|
1485
|
+
// Pull-on-event receiver (US-009). The injected SyncEngineFn bridges a
|
|
1486
|
+
// received PushEvent → a TARGETED pull pass routed by relativePath, funneled
|
|
1487
|
+
// through the SAME `runGuarded` mutex as the poll loop + watcher push so a
|
|
1488
|
+
// pull-on-event never overlaps an in-flight pass. Started AFTER the watcher
|
|
1489
|
+
// so a live event can't race a half-built daemon. Default factory = noop
|
|
1490
|
+
// (dormant); a real SqsPushReceiver is injected by a later release once the
|
|
1491
|
+
// server-side per-client SQS queue is provisioned.
|
|
1492
|
+
const receiverSyncFn: SyncEngineFn = async (ctx) => {
|
|
1493
|
+
if (stopped) return;
|
|
1494
|
+
const route = routeChangeToTarget(ctx.event.relativePath);
|
|
1495
|
+
if (!route) return;
|
|
1496
|
+
const targetedArgv = buildTargetedPullArgv(route, passArgv);
|
|
1497
|
+
await runGuarded(() => runPass(targetedArgv));
|
|
1498
|
+
};
|
|
1499
|
+
const createReceiver =
|
|
1500
|
+
deps.createReceiver ?? (() => new NoopPushReceiver());
|
|
1501
|
+
receiver = createReceiver({ syncFn: receiverSyncFn, hqRoot });
|
|
1502
|
+
// Fire-and-forget start: a receiver's start() kicks off its own poll loop
|
|
1503
|
+
// (SqsPushReceiver) or trivially flips connected (noop) — it must NOT block
|
|
1504
|
+
// the runner's poll loop from entering. Errors are swallowed; the cadence
|
|
1505
|
+
// poll is the safety net regardless of receiver health. (The await-free
|
|
1506
|
+
// start also keeps the poll loop's microtask timing identical to the
|
|
1507
|
+
// pre-US-009 wiring.)
|
|
1508
|
+
void Promise.resolve(receiver.start()).catch(() => undefined);
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// ---- clean shutdown --------------------------------------------------
|
|
1512
|
+
// SIGTERM (menubar stop_daemon) / SIGINT must tear down BOTH the watcher and
|
|
1513
|
+
// the poll loop with no leaked timers or fds. We flip `stopped`, dispose the
|
|
1514
|
+
// watcher + driver, and let the poll loop observe `stopped` on its next tick.
|
|
1515
|
+
let resolveStopped: (() => void) | null = null;
|
|
1516
|
+
const stoppedSignal = new Promise<void>((resolve) => {
|
|
1517
|
+
resolveStopped = resolve;
|
|
1518
|
+
});
|
|
1519
|
+
const shutdown = (): void => {
|
|
1520
|
+
if (stopped) return;
|
|
1521
|
+
stopped = true;
|
|
1522
|
+
// Dispose the receiver FIRST (mirror of the PushTransport ordering:
|
|
1523
|
+
// inbound subscription torn down before the watcher) so no new
|
|
1524
|
+
// pull-on-event fires mid-teardown. dispose() is async (it drains the
|
|
1525
|
+
// in-flight pull up to its own deadline); fire-and-forget here — the
|
|
1526
|
+
// receiver's internal drain + the runGuarded mutex bound the work, and
|
|
1527
|
+
// SIGTERM teardown must not block. Errors are swallowed.
|
|
1528
|
+
try {
|
|
1529
|
+
void receiver?.dispose();
|
|
1530
|
+
} catch {
|
|
1531
|
+
/* ignore */
|
|
1532
|
+
}
|
|
1533
|
+
try {
|
|
1534
|
+
driver?.dispose();
|
|
1535
|
+
} catch {
|
|
1536
|
+
/* ignore */
|
|
1537
|
+
}
|
|
1538
|
+
try {
|
|
1539
|
+
watcher?.dispose();
|
|
1540
|
+
} catch {
|
|
1541
|
+
/* ignore */
|
|
1542
|
+
}
|
|
1543
|
+
resolveStopped?.();
|
|
1544
|
+
};
|
|
1545
|
+
const onShutdownSignal =
|
|
1546
|
+
deps.onShutdownSignal ??
|
|
1547
|
+
((handler: () => void) => {
|
|
1548
|
+
const wrapped = () => handler();
|
|
1549
|
+
process.on("SIGTERM", wrapped);
|
|
1550
|
+
process.on("SIGINT", wrapped);
|
|
1551
|
+
return () => {
|
|
1552
|
+
process.off("SIGTERM", wrapped);
|
|
1553
|
+
process.off("SIGINT", wrapped);
|
|
1554
|
+
};
|
|
1555
|
+
});
|
|
1556
|
+
detachSignal = onShutdownSignal(shutdown);
|
|
1557
|
+
|
|
1558
|
+
try {
|
|
1559
|
+
while (!stopped) {
|
|
1560
|
+
const result = await runGuarded(() => runPass(passArgv));
|
|
1561
|
+
// A poll pass that was skipped because a watcher push held the guard is
|
|
1562
|
+
// benign — the next iteration retries after the poll interval.
|
|
1563
|
+
if (typeof result === "number" && result !== 0) {
|
|
1564
|
+
return result;
|
|
1565
|
+
}
|
|
1566
|
+
// Sleep the poll interval, but wake early on shutdown so SIGTERM stops
|
|
1567
|
+
// the loop promptly instead of waiting out a 10-minute cycle.
|
|
1568
|
+
await Promise.race([sleep(pollMs), stoppedSignal]);
|
|
1569
|
+
}
|
|
1570
|
+
return 0;
|
|
1571
|
+
} finally {
|
|
1572
|
+
shutdown();
|
|
1573
|
+
detachSignal?.();
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* Extract the `--hq-root` / `--on-conflict` pair-flags from a base argv so a
|
|
1579
|
+
* re-targeted push pass inherits the same root and conflict policy. Pure
|
|
1580
|
+
* helper used by the event-push targeted-push composer.
|
|
1581
|
+
*/
|
|
1582
|
+
function carriedFlags(baseArgv: string[]): string[] {
|
|
1583
|
+
const carried: string[] = [];
|
|
1584
|
+
for (let i = 0; i < baseArgv.length; i++) {
|
|
1585
|
+
const a = baseArgv[i];
|
|
1586
|
+
if (a === "--hq-root" || a === "--on-conflict") {
|
|
1587
|
+
carried.push(a);
|
|
1588
|
+
if (baseArgv[i + 1] !== undefined) carried.push(baseArgv[++i]);
|
|
1589
|
+
}
|
|
1194
1590
|
}
|
|
1591
|
+
return carried;
|
|
1195
1592
|
}
|
|
1196
1593
|
|
|
1197
1594
|
if (isDirectInvocation) {
|
package/src/index.ts
CHANGED
|
@@ -281,3 +281,41 @@ export {
|
|
|
281
281
|
_setSignalsS3Factory,
|
|
282
282
|
_resetSignalsS3Factory,
|
|
283
283
|
} from "./signals/internals.js";
|
|
284
|
+
|
|
285
|
+
// Event-driven sync — PushEvent wire contract + PushTransport shipping seam
|
|
286
|
+
// (ported from hq-pro PR #112 per project event-driven-sync-menubar US-007).
|
|
287
|
+
export {
|
|
288
|
+
CONTENT_HASH_PATTERN,
|
|
289
|
+
ISO8601_DATETIME_PATTERN,
|
|
290
|
+
PushEventSchema,
|
|
291
|
+
PushEventDecodeError,
|
|
292
|
+
encodePushEvent,
|
|
293
|
+
decodePushEvent,
|
|
294
|
+
NoopPushTransport,
|
|
295
|
+
HttpPushTransport,
|
|
296
|
+
FEATURE_FLAG_TENANTS_ENV_VAR,
|
|
297
|
+
FEATURE_FLAG_LEGACY_GLOBAL_ENV_VAR,
|
|
298
|
+
EnvTenantListFlagProvider,
|
|
299
|
+
StaticFlagProvider,
|
|
300
|
+
defaultFlagProvider,
|
|
301
|
+
} from "./sync/index.js";
|
|
302
|
+
export type {
|
|
303
|
+
PushEvent,
|
|
304
|
+
PushEventDecodeIssue,
|
|
305
|
+
PushTransport,
|
|
306
|
+
HttpPushTransportOptions,
|
|
307
|
+
AuthTokenSource,
|
|
308
|
+
FetchLike,
|
|
309
|
+
EventDrivenPushFlagProvider,
|
|
310
|
+
FlagProviderWarnFn,
|
|
311
|
+
EnvTenantListFlagProviderOptions,
|
|
312
|
+
} from "./sync/index.js";
|
|
313
|
+
|
|
314
|
+
// US-008 — watcher PushEvent emitter (bridges TreeWatcher → PushTransport,
|
|
315
|
+
// flag-gated, failure-safe).
|
|
316
|
+
export { PushEventEmitter } from "./watcher.js";
|
|
317
|
+
export type {
|
|
318
|
+
PushEventEmitterOptions,
|
|
319
|
+
TreeChangeBatch,
|
|
320
|
+
TreeChangeListener,
|
|
321
|
+
} from "./watcher.js";
|