@indigoai-us/hq-cloud 5.24.0 → 5.26.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/dist/bin/sync-runner.d.ts +151 -17
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +280 -18
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +429 -15
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +9 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +54 -1
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +6 -3
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +21 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.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/personal-vault-exclusions.d.ts +128 -0
- package/dist/personal-vault-exclusions.d.ts.map +1 -0
- package/dist/personal-vault-exclusions.js +231 -0
- package/dist/personal-vault-exclusions.js.map +1 -0
- package/dist/personal-vault-exclusions.test.d.ts +22 -0
- package/dist/personal-vault-exclusions.test.d.ts.map +1 -0
- package/dist/personal-vault-exclusions.test.js +198 -0
- package/dist/personal-vault-exclusions.test.js.map +1 -0
- package/dist/sync/index.d.ts +11 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +9 -0
- package/dist/sync/index.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-transport.d.ts +67 -0
- package/dist/sync/push-transport.d.ts.map +1 -0
- package/dist/sync/push-transport.js +66 -0
- package/dist/sync/push-transport.js.map +1 -0
- package/dist/watcher.d.ts +160 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +298 -0
- 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 +3 -2
- package/src/bin/sync-runner.test.ts +557 -15
- package/src/bin/sync-runner.ts +404 -27
- package/src/cli/share.test.ts +8 -3
- package/src/cli/share.ts +66 -1
- package/src/cli/sync.ts +22 -0
- package/src/index.ts +27 -0
- package/src/personal-vault-exclusions.test.ts +256 -0
- package/src/personal-vault-exclusions.ts +277 -0
- package/src/sync/index.ts +19 -0
- package/src/sync/push-event.test.ts +224 -0
- package/src/sync/push-event.ts +208 -0
- package/src/sync/push-transport.ts +84 -0
- package/src/watcher.test.ts +388 -0
- package/src/watcher.ts +386 -0
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
* --company <slug-or-uid> Sync a single company (alternative to --companies)
|
|
15
15
|
* --on-conflict <strategy> abort | overwrite | keep (default: abort)
|
|
16
16
|
* --hq-root <path> Local HQ directory (default: $HOME/hq)
|
|
17
|
+
* --skip-personal Drop the personal target from the --companies
|
|
18
|
+
* fanout. Combined with HQ_SYNC_SKIP_PERSONAL env
|
|
19
|
+
* (either truthy disables personal sync). No-op
|
|
20
|
+
* outside --companies mode.
|
|
17
21
|
* --json Ignored — ndjson on stdout is the default and
|
|
18
22
|
* only output mode. Accepted for symmetry with the
|
|
19
23
|
* AppBar's argv in case someone passes it.
|
|
@@ -55,6 +59,7 @@ import { type VaultServiceConfig, type Membership, type EntityInfo, type Pending
|
|
|
55
59
|
import { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL, computePersonalVaultPaths } from "../personal-vault.js";
|
|
56
60
|
import type { SyncOptions, SyncResult, SyncProgressEvent } from "../cli/sync.js";
|
|
57
61
|
import type { ShareOptions, ShareResult } from "../cli/share.js";
|
|
62
|
+
import { type Clock } from "../watcher.js";
|
|
58
63
|
/**
|
|
59
64
|
* Sync direction for a run.
|
|
60
65
|
*
|
|
@@ -70,24 +75,48 @@ import type { ShareOptions, ShareResult } from "../cli/share.js";
|
|
|
70
75
|
export type Direction = "pull" | "push" | "both";
|
|
71
76
|
/**
|
|
72
77
|
* Delete-propagation policy honored by the push leg of bidirectional sync.
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
78
|
+
*
|
|
79
|
+
* Default `"currency-gated"` in 5.25 — flipped from `"owned-only"` after
|
|
80
|
+
* one machine (Indigo / corey) ran the 5.24 code path through real syncs
|
|
81
|
+
* for a week without surfacing surprise behavior. Currency-gated does a
|
|
82
|
+
* per-file ETag HEAD before propagating any local-delete to S3: if the
|
|
83
|
+
* remote object's current ETag no longer matches the journal's last-
|
|
84
|
+
* recorded one, the delete is refused and the next pull leg re-pulls the
|
|
85
|
+
* file via the standard 3-way merge path. This is strictly safer than
|
|
86
|
+
* `owned-only` (which propagates any local-delete the journal can prove
|
|
87
|
+
* came from this device) — the only delete-class that changes behavior
|
|
88
|
+
* is "deleted locally + modified remotely by another device", which
|
|
89
|
+
* previously destroyed remote work and now becomes a pull-and-conflict.
|
|
81
90
|
*
|
|
82
91
|
* Env override `HQ_SYNC_DELETE_POLICY=owned-only|all|currency-gated` is
|
|
83
|
-
* also the rollback knob —
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* recommended default.
|
|
92
|
+
* also the rollback knob — anyone surprised by 5.25's flip can revert
|
|
93
|
+
* to `owned-only` without redeploying. `all` is the unsafe-mirror mode
|
|
94
|
+
* previously used by the runner pre-5.20 — included only as an
|
|
95
|
+
* emergency reconcile lever, not a recommended default.
|
|
88
96
|
*/
|
|
89
97
|
export type DeletePropagationPolicy = "currency-gated" | "owned-only" | "all";
|
|
90
98
|
export declare function resolveDeletePolicy(): DeletePropagationPolicy;
|
|
99
|
+
/**
|
|
100
|
+
* Resolve whether to skip the personal target in a `--companies` fanout.
|
|
101
|
+
*
|
|
102
|
+
* Two inputs combine: the `--skip-personal` CLI flag (parsed into
|
|
103
|
+
* `ParsedArgs.skipPersonal`) and the `HQ_SYNC_SKIP_PERSONAL` env var. Either
|
|
104
|
+
* being truthy skips the personal target — flag wins on conflict (CLI
|
|
105
|
+
* flag is the explicit-for-this-invocation knob, env is the persistent
|
|
106
|
+
* default usually set by the menubar in the spawned child process).
|
|
107
|
+
*
|
|
108
|
+
* Env truthy values: `1`, `true`, `yes` (case-insensitive). Anything else
|
|
109
|
+
* (including missing) is treated as falsy — same shape as classic
|
|
110
|
+
* Unix opt-in env conventions; conservative to avoid surprising opt-outs.
|
|
111
|
+
*
|
|
112
|
+
* Use case: the menubar app exposes a "Sync personal vault" toggle in
|
|
113
|
+
* Settings (default ON, matching the auto-provisioning UX). When the user
|
|
114
|
+
* flips it off, the menubar spawns `hq sync` with this env set so the
|
|
115
|
+
* fanout drops the personal target before walking the user's entire HQ
|
|
116
|
+
* tree (a sync that would otherwise scan thousands of files, including
|
|
117
|
+
* the new personal-vault default exclusions, just to do nothing useful).
|
|
118
|
+
*/
|
|
119
|
+
export declare function resolveSkipPersonal(flag: boolean): boolean;
|
|
91
120
|
export { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL, computePersonalVaultPaths };
|
|
92
121
|
/**
|
|
93
122
|
* Every event the runner emits. Channel routing (stdout vs stderr) is
|
|
@@ -153,12 +182,19 @@ export type RunnerEvent = {
|
|
|
153
182
|
/**
|
|
154
183
|
* Upload counters. Always emitted (0 when the run was pull-only) so
|
|
155
184
|
* downstream consumers don't need to conditionally read the field.
|
|
156
|
-
* Tauri's `SyncCompleteEvent` ignores extra fields today; adding them
|
|
157
|
-
* to the Rust struct is a follow-up when the UI needs to surface push
|
|
158
|
-
* totals.
|
|
159
185
|
*/
|
|
160
186
|
filesUploaded: number;
|
|
161
187
|
bytesUploaded: number;
|
|
188
|
+
/**
|
|
189
|
+
* Push-side counters added in 5.25. Always emitted as numbers (0
|
|
190
|
+
* when no push leg ran). Tauri's `SyncCompleteEvent` carries them
|
|
191
|
+
* as Option<u32> for back-compat with <5.25 engines that don't
|
|
192
|
+
* include them; structural-typing-wise, the union just adds
|
|
193
|
+
* properties on top of `SyncResult`.
|
|
194
|
+
*/
|
|
195
|
+
filesTombstoned: number;
|
|
196
|
+
filesRefusedStale: number;
|
|
197
|
+
filesExcludedByPolicy: number;
|
|
162
198
|
} & SyncResult) | {
|
|
163
199
|
type: "all-complete";
|
|
164
200
|
companiesAttempted: number;
|
|
@@ -289,5 +325,103 @@ export declare function runRunner(argv: string[], deps?: RunnerDeps): Promise<nu
|
|
|
289
325
|
* exit 0 today and so will retry — acceptable noise for the beta; deal with
|
|
290
326
|
* it via a richer return shape if it shows up in Sentry.
|
|
291
327
|
*/
|
|
292
|
-
|
|
328
|
+
/**
|
|
329
|
+
* Test/event-driven seam (US-001).
|
|
330
|
+
*
|
|
331
|
+
* `runRunnerWithLoop` performs an unbounded poll loop in production. To make
|
|
332
|
+
* the loop deterministically testable (US-003 wires the event-driven watcher
|
|
333
|
+
* into this same loop), the inter-pass sleep is injectable via `deps.sleep`.
|
|
334
|
+
* The default uses the host timer and preserves exact production behavior.
|
|
335
|
+
*
|
|
336
|
+
* A test (or US-003's wiring) injects a fake sleep that resolves immediately
|
|
337
|
+
* and/or coordinates with the {@link WatchPushDriver} seam in `../watcher.js`,
|
|
338
|
+
* so the loop can be exercised without a real 10-minute wait.
|
|
339
|
+
*/
|
|
340
|
+
export interface RunnerLoopDeps {
|
|
341
|
+
/** Sleep `ms` between passes. Default: host setTimeout. */
|
|
342
|
+
sleep?: (ms: number) => Promise<void>;
|
|
343
|
+
/**
|
|
344
|
+
* Run a single sync pass. Defaults to {@link runRunner}. Injected by tests
|
|
345
|
+
* (and the event-push wiring) so the poll loop and the watcher-triggered
|
|
346
|
+
* targeted push share one seam and one in-flight guard. The default ignores
|
|
347
|
+
* `deps` and forwards just the argv to `runRunner`.
|
|
348
|
+
*/
|
|
349
|
+
runPass?: (passArgv: string[]) => Promise<number>;
|
|
350
|
+
/**
|
|
351
|
+
* Clock seam for the event-push watcher's debounce window. Defaults to
|
|
352
|
+
* {@link systemClock}; tests inject a `FakeClock` to advance the window
|
|
353
|
+
* deterministically. Only consulted when `--event-push` is on.
|
|
354
|
+
*/
|
|
355
|
+
clock?: Clock;
|
|
356
|
+
/**
|
|
357
|
+
* Factory for the file watcher used in event-push mode. Defaults to a real
|
|
358
|
+
* {@link TreeWatcher} over `hqRoot`. Tests inject a stub exposing the same
|
|
359
|
+
* `onChange`/`start`/`stop`/`dispose` surface so no real chokidar runs.
|
|
360
|
+
*/
|
|
361
|
+
createWatcher?: (opts: {
|
|
362
|
+
hqRoot: string;
|
|
363
|
+
debounceMs: number;
|
|
364
|
+
clock: Clock;
|
|
365
|
+
}) => WatcherSurface;
|
|
366
|
+
/**
|
|
367
|
+
* Register a one-shot shutdown signal handler. Defaults to listening for
|
|
368
|
+
* SIGTERM/SIGINT on `process`. Tests inject a controllable trigger to assert
|
|
369
|
+
* clean teardown without sending real signals. The returned fn detaches the
|
|
370
|
+
* handler (called during teardown so tests don't leak listeners).
|
|
371
|
+
*/
|
|
372
|
+
onShutdownSignal?: (handler: () => void) => () => void;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* The minimal watcher surface the loop drives. {@link TreeWatcher} satisfies
|
|
376
|
+
* it; tests inject a lighter stub. Kept narrow so the loop never reaches past
|
|
377
|
+
* the lifecycle + change-subscription contract.
|
|
378
|
+
*
|
|
379
|
+
* `onChange`'s listener receives an OPTIONAL changed relative path. The real
|
|
380
|
+
* {@link TreeWatcher} emits a bare debounced signal (no path) — in that case
|
|
381
|
+
* the loop routes the targeted push to the personal vault. A path-aware
|
|
382
|
+
* watcher (or a test stub) can pass the changed `companies/<slug>/...`
|
|
383
|
+
* relative path so the loop targets just that company.
|
|
384
|
+
*/
|
|
385
|
+
export interface WatcherSurface {
|
|
386
|
+
onChange(listener: (changedRelPath?: string) => void): () => void;
|
|
387
|
+
start(): void;
|
|
388
|
+
stop(): void;
|
|
389
|
+
dispose(): void;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Route a changed relative path to the push target that owns it.
|
|
393
|
+
*
|
|
394
|
+
* - `companies/<slug>/...` → a single-company push for `<slug>` (the targeted
|
|
395
|
+
* pass per PRD decision — push only the changed company, not a full
|
|
396
|
+
* `--companies` fanout).
|
|
397
|
+
* - anything else under hqRoot → the personal target (a `--companies` push
|
|
398
|
+
* restricted to personal via the personal-vault scope; modeled here as the
|
|
399
|
+
* "personal" route the caller maps to the right argv).
|
|
400
|
+
*
|
|
401
|
+
* Returns `null` for paths the routing cannot attribute (defensive — the
|
|
402
|
+
* watcher's filter already drops excluded top-levels, so this is belt-and-
|
|
403
|
+
* suspenders for synthetic events).
|
|
404
|
+
*/
|
|
405
|
+
export declare function routeChangeToTarget(relPath: string): {
|
|
406
|
+
kind: "company";
|
|
407
|
+
slug: string;
|
|
408
|
+
} | {
|
|
409
|
+
kind: "personal";
|
|
410
|
+
} | null;
|
|
411
|
+
/**
|
|
412
|
+
* Build the argv for a targeted push pass from a routed change. The push runs
|
|
413
|
+
* `--direction push` for just the routed target so a local edit propagates in
|
|
414
|
+
* seconds without a full fanout. Company routes use `--company <slug>`;
|
|
415
|
+
* personal routes use `--companies --direction push` (the personal-vault scope
|
|
416
|
+
* is resolved inside runRunner's fanout; skipUnchanged no-ops the company
|
|
417
|
+
* subtrees that didn't change). Inherits `--hq-root` / `--on-conflict` from
|
|
418
|
+
* the base argv. Pure helper, exported for unit testing the routing→argv map.
|
|
419
|
+
*/
|
|
420
|
+
export declare function buildTargetedPushArgv(route: {
|
|
421
|
+
kind: "company";
|
|
422
|
+
slug: string;
|
|
423
|
+
} | {
|
|
424
|
+
kind: "personal";
|
|
425
|
+
}, baseArgv: string[]): string[];
|
|
426
|
+
export declare function runRunnerWithLoop(argv: string[], deps?: RunnerLoopDeps): Promise<number>;
|
|
293
427
|
//# 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
|
|
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;AAIjE,OAAO,EAIL,KAAK,KAAK,EACX,MAAM,eAAe,CAAC;AAEvB;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAuBjD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,MAAM,uBAAuB,GAAG,gBAAgB,GAAG,YAAY,GAAG,KAAK,CAAC;AAE9E,wBAAgB,mBAAmB,IAAI,uBAAuB,CAM7D;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAI1D;AAMD,OAAO,EAAE,iCAAiC,EAAE,yBAAyB,EAAE,CAAC;AAMxE;;;;;;;;;GASG;AACH,MAAM,MAAM,WAAW,GACnB;IAAE,IAAI,EAAE,cAAc,CAAA;CAAE,GACxB;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACvC;IACE,IAAI,EAAE,aAAa,CAAC;IACpB,SAAS,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAChE,GACD,CAAC;IACC;;;;;;;OAOG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB,GAAG,IAAI,CAAC,OAAO,CAAC,iBAAiB,EAAE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,EAAE,MAAM,CAAC,CAAC,GAC/D,CAAC;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,OAAO,CAAC,iBAAiB,EAAE;IAAE,IAAI,EAAE,UAAU,CAAA;CAAE,CAAC,EAAE,MAAM,CAAC,CAAC,GACxG,CAAC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,OAAO,CAAC,iBAAiB,EAAE;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC,EAAE,MAAM,CAAC,CAAC,GACnG,CAAC;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,OAAO,CAAC,iBAAiB,EAAE;IAAE,IAAI,EAAE,UAAU,CAAA;CAAE,CAAC,EAAE,MAAM,CAAC,CAAC,GACxG;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC,CAAA;CAAE,GAC7G,CAAC;IACC,IAAI,EAAE,UAAU,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB;;;;;;OAMG;IACH,eAAe,EAAE,MAAM,CAAC;IACxB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,qBAAqB,EAAE,MAAM,CAAC;CAC/B,GAAG,UAAU,CAAC,GACf;IACE,IAAI,EAAE,cAAc,CAAC;IACrB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,eAAe,EAAE,MAAM,CAAC;IACxB,eAAe,EAAE,MAAM,CAAC;IACxB,gDAAgD;IAChD,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,aAAa,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC,CAAC;IACpF,MAAM,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpD;;;;;;;;;;OAUG;IACH,OAAO,EAAE,OAAO,CAAC;IACjB;;;;;;;;;OASG;IACH,SAAS,EAAE,KAAK,CAAC;QACf,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,UAAU,GAAG,SAAS,GAAG,SAAS,CAAC;QAC3C,eAAe,EAAE,MAAM,CAAC;QACxB,eAAe,EAAE,MAAM,CAAC;QACxB,aAAa,EAAE,MAAM,CAAC;QACtB,aAAa,EAAE,MAAM,CAAC;KACvB,CAAC,CAAC;CACJ,CAAC;AAEN;;;;;;;GAOG;AACH,MAAM,WAAW,kBAAkB;IACjC,iBAAiB,EAAE,MAAM,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC;IAC/C,2BAA2B,EAAE,MAAM,OAAO,CAAC,oBAAoB,EAAE,CAAC,CAAC;IACnE,0BAA0B,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,oBAAoB,EAAE,CAAC,KAAK,EAAE;QAC5B,QAAQ,EAAE,MAAM,CAAC;QACjB,WAAW,EAAE,MAAM,CAAC;KACrB,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IAC1B,MAAM,EAAE;QACN,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;QAC1C,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC;KACrD,CAAC;CACH;AAED,mEAAmE;AACnE,UAAU,aAAa;IACrB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,UAAU;IACzB,kEAAkE;IAClE,MAAM,CAAC,EAAE;QAAE,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,GAAG,IAAI,CAAA;KAAE,CAAC;IACtD,gEAAgE;IAChE,MAAM,CAAC,EAAE;QAAE,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,GAAG,IAAI,CAAA;KAAE,CAAC;IACtD,uFAAuF;IACvF,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IACvC;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,MAAM,aAAa,GAAG,IAAI,CAAC;IAC9C;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,CAAC,MAAM,EAAE,kBAAkB,KAAK,kBAAkB,CAAC;IACvE,kDAAkD;IAClD,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IACrD,kEAAkE;IAClE,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,YAAY,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;IACxD;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC;AAuQD,wBAAsB,SAAS,CAC7B,IAAI,EAAE,MAAM,EAAE,EACd,IAAI,GAAE,UAAe,GACpB,OAAO,CAAC,MAAM,CAAC,CA4hBjB;AA4BD;;;;;;;GAOG;AACH;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,cAAc;IAC7B,2DAA2D;IAC3D,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC;;;;;OAKG;IACH,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAClD;;;;OAIG;IACH,KAAK,CAAC,EAAE,KAAK,CAAC;IACd;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE;QACrB,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,EAAE,MAAM,CAAC;QACnB,KAAK,EAAE,KAAK,CAAC;KACd,KAAK,cAAc,CAAC;IACrB;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,CAAC;CACxD;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,QAAQ,EAAE,CAAC,cAAc,CAAC,EAAE,MAAM,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IAClE,KAAK,IAAI,IAAI,CAAC;IACd,IAAI,IAAI,IAAI,CAAC;IACb,OAAO,IAAI,IAAI,CAAC;CACjB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,MAAM,GACd;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,UAAU,CAAA;CAAE,GAAG,IAAI,CAWjE;AAED;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,UAAU,CAAA;CAAE,EAC/D,QAAQ,EAAE,MAAM,EAAE,GACjB,MAAM,EAAE,CAMV;AAED,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,MAAM,EAAE,EACd,IAAI,GAAE,cAAmB,GACxB,OAAO,CAAC,MAAM,CAAC,CAsJjB"}
|
package/dist/bin/sync-runner.js
CHANGED
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
* --company <slug-or-uid> Sync a single company (alternative to --companies)
|
|
15
15
|
* --on-conflict <strategy> abort | overwrite | keep (default: abort)
|
|
16
16
|
* --hq-root <path> Local HQ directory (default: $HOME/hq)
|
|
17
|
+
* --skip-personal Drop the personal target from the --companies
|
|
18
|
+
* fanout. Combined with HQ_SYNC_SKIP_PERSONAL env
|
|
19
|
+
* (either truthy disables personal sync). No-op
|
|
20
|
+
* outside --companies mode.
|
|
17
21
|
* --json Ignored — ndjson on stdout is the default and
|
|
18
22
|
* only output mode. Accepted for symmetry with the
|
|
19
23
|
* AppBar's argv in case someone passes it.
|
|
@@ -61,6 +65,7 @@ import { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL, computePersonalVaultPaths, } from ".
|
|
|
61
65
|
import { sync as defaultSync } from "../cli/sync.js";
|
|
62
66
|
import { share as defaultShare } from "../cli/share.js";
|
|
63
67
|
import { collectAndSendTelemetry } from "../telemetry.js";
|
|
68
|
+
import { TreeWatcher, WatchPushDriver, systemClock, } from "../watcher.js";
|
|
64
69
|
// ---------------------------------------------------------------------------
|
|
65
70
|
// Defaults — mirror `hq-cli/src/utils/cognito-session.ts`. Inlined (not
|
|
66
71
|
// imported) to avoid a circular dep between hq-cli and hq-cloud. If these
|
|
@@ -82,7 +87,33 @@ export function resolveDeletePolicy() {
|
|
|
82
87
|
if (env === "owned-only" || env === "all" || env === "currency-gated") {
|
|
83
88
|
return env;
|
|
84
89
|
}
|
|
85
|
-
return "
|
|
90
|
+
return "currency-gated";
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Resolve whether to skip the personal target in a `--companies` fanout.
|
|
94
|
+
*
|
|
95
|
+
* Two inputs combine: the `--skip-personal` CLI flag (parsed into
|
|
96
|
+
* `ParsedArgs.skipPersonal`) and the `HQ_SYNC_SKIP_PERSONAL` env var. Either
|
|
97
|
+
* being truthy skips the personal target — flag wins on conflict (CLI
|
|
98
|
+
* flag is the explicit-for-this-invocation knob, env is the persistent
|
|
99
|
+
* default usually set by the menubar in the spawned child process).
|
|
100
|
+
*
|
|
101
|
+
* Env truthy values: `1`, `true`, `yes` (case-insensitive). Anything else
|
|
102
|
+
* (including missing) is treated as falsy — same shape as classic
|
|
103
|
+
* Unix opt-in env conventions; conservative to avoid surprising opt-outs.
|
|
104
|
+
*
|
|
105
|
+
* Use case: the menubar app exposes a "Sync personal vault" toggle in
|
|
106
|
+
* Settings (default ON, matching the auto-provisioning UX). When the user
|
|
107
|
+
* flips it off, the menubar spawns `hq sync` with this env set so the
|
|
108
|
+
* fanout drops the personal target before walking the user's entire HQ
|
|
109
|
+
* tree (a sync that would otherwise scan thousands of files, including
|
|
110
|
+
* the new personal-vault default exclusions, just to do nothing useful).
|
|
111
|
+
*/
|
|
112
|
+
export function resolveSkipPersonal(flag) {
|
|
113
|
+
if (flag)
|
|
114
|
+
return true;
|
|
115
|
+
const env = (process.env.HQ_SYNC_SKIP_PERSONAL ?? "").toLowerCase();
|
|
116
|
+
return env === "1" || env === "true" || env === "yes";
|
|
86
117
|
}
|
|
87
118
|
// Personal-vault scope (exclusion list + path computer) lives in
|
|
88
119
|
// `../personal-vault.ts` so the `hq sync` CLI and this runner share the same
|
|
@@ -159,6 +190,8 @@ function parseArgs(argv) {
|
|
|
159
190
|
let direction = "pull";
|
|
160
191
|
let watch = false;
|
|
161
192
|
let pollRemoteMs;
|
|
193
|
+
let skipPersonal = false;
|
|
194
|
+
let eventPush = false;
|
|
162
195
|
for (let i = 0; i < argv.length; i++) {
|
|
163
196
|
const arg = argv[i];
|
|
164
197
|
switch (arg) {
|
|
@@ -214,6 +247,18 @@ function parseArgs(argv) {
|
|
|
214
247
|
case "--json":
|
|
215
248
|
// Accepted but ignored — ndjson is the only output mode.
|
|
216
249
|
break;
|
|
250
|
+
case "--skip-personal":
|
|
251
|
+
// Drop the personal target from the fanout. No-op outside
|
|
252
|
+
// --companies mode. Combined with HQ_SYNC_SKIP_PERSONAL env via
|
|
253
|
+
// resolveSkipPersonal().
|
|
254
|
+
skipPersonal = true;
|
|
255
|
+
break;
|
|
256
|
+
case "--event-push":
|
|
257
|
+
// Phase 1 event-driven push enable flag. Requires --watch (validated
|
|
258
|
+
// below). Gated OFF by default; the menubar only passes it for
|
|
259
|
+
// @getindigo.ai identities for the first release.
|
|
260
|
+
eventPush = true;
|
|
261
|
+
break;
|
|
217
262
|
default:
|
|
218
263
|
return { error: `Unknown argument: ${arg}` };
|
|
219
264
|
}
|
|
@@ -227,7 +272,20 @@ function parseArgs(argv) {
|
|
|
227
272
|
if (pollRemoteMs !== undefined && !watch) {
|
|
228
273
|
return { error: "--poll-remote-ms requires --watch" };
|
|
229
274
|
}
|
|
230
|
-
|
|
275
|
+
if (eventPush && !watch) {
|
|
276
|
+
return { error: "--event-push requires --watch" };
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
companies,
|
|
280
|
+
company,
|
|
281
|
+
onConflict,
|
|
282
|
+
hqRoot,
|
|
283
|
+
direction,
|
|
284
|
+
watch,
|
|
285
|
+
pollRemoteMs,
|
|
286
|
+
skipPersonal,
|
|
287
|
+
eventPush,
|
|
288
|
+
};
|
|
231
289
|
}
|
|
232
290
|
// ---------------------------------------------------------------------------
|
|
233
291
|
// Telemetry default — closes over the runner's vault client. Skipped when
|
|
@@ -421,7 +479,15 @@ export async function runRunner(argv, deps = {}) {
|
|
|
421
479
|
}
|
|
422
480
|
plan.push({ uid: m.companyUid, slug, ...(name ? { name } : {}) });
|
|
423
481
|
}
|
|
424
|
-
if (parsed.companies) {
|
|
482
|
+
if (parsed.companies && !resolveSkipPersonal(parsed.skipPersonal)) {
|
|
483
|
+
// Personal-target fanout slot. Skipped entirely when --skip-personal
|
|
484
|
+
// (or HQ_SYNC_SKIP_PERSONAL=1) is set — see resolveSkipPersonal doc for
|
|
485
|
+
// the rationale (menubar opt-out for users who only want company sync).
|
|
486
|
+
// When skipped, the fanout-plan event below carries only company
|
|
487
|
+
// memberships and no "personal" slug; downstream consumers (menubar
|
|
488
|
+
// workspaces row, status surfaces) should already tolerate that
|
|
489
|
+
// shape since pre-5.25 fanout often had it (a user with no person
|
|
490
|
+
// entity yet, or before the canonical-person-entity machinery landed).
|
|
425
491
|
const persons = await client.entity.listByType("person");
|
|
426
492
|
const pick = pickCanonicalPersonEntity(persons);
|
|
427
493
|
if (pick?.bucketName) {
|
|
@@ -529,6 +595,7 @@ export async function runRunner(argv, deps = {}) {
|
|
|
529
595
|
filesDeleted: 0,
|
|
530
596
|
filesTombstoned: 0,
|
|
531
597
|
filesRefusedStale: 0,
|
|
598
|
+
filesExcludedByPolicy: 0,
|
|
532
599
|
conflictPaths: [],
|
|
533
600
|
aborted: false,
|
|
534
601
|
};
|
|
@@ -625,6 +692,17 @@ export async function runRunner(argv, deps = {}) {
|
|
|
625
692
|
filesUploaded: pushResult.filesUploaded,
|
|
626
693
|
bytesUploaded: pushResult.bytesUploaded,
|
|
627
694
|
filesSkipped: pullResult.filesSkipped + pushResult.filesSkipped,
|
|
695
|
+
// Push-side counters surfaced on `complete` so the menubar's
|
|
696
|
+
// `SyncCompleteEvent` (which carries them as Option<u32> for
|
|
697
|
+
// back-compat with pre-5.25 engines) can render the new totals.
|
|
698
|
+
// Always emitted as numbers (0 when no push leg ran) so Rust's
|
|
699
|
+
// serde decodes them as `Some(0)` rather than `None` — distinct
|
|
700
|
+
// from the legacy-engine `None` and useful when the UI wants to
|
|
701
|
+
// distinguish "engine ran, nothing tombstoned" from "engine
|
|
702
|
+
// didn't report".
|
|
703
|
+
filesTombstoned: pushResult.filesTombstoned,
|
|
704
|
+
filesRefusedStale: pushResult.filesRefusedStale,
|
|
705
|
+
filesExcludedByPolicy: pushResult.filesExcludedByPolicy,
|
|
628
706
|
// Sourced from the merged path list so push-side conflicts are
|
|
629
707
|
// counted too — `ShareResult` doesn't expose a numeric counter,
|
|
630
708
|
// and using `pullResult.conflicts` alone silently dropped any
|
|
@@ -663,6 +741,13 @@ export async function runRunner(argv, deps = {}) {
|
|
|
663
741
|
filesUploaded: state.filesUploaded,
|
|
664
742
|
bytesUploaded: state.bytesUploaded,
|
|
665
743
|
filesSkipped: 0,
|
|
744
|
+
// Mid-flight throw: we have no clean ShareResult to read these
|
|
745
|
+
// from. Report 0 so the event shape stays stable; the partial
|
|
746
|
+
// counts above already reflect what actually moved before the
|
|
747
|
+
// throw.
|
|
748
|
+
filesTombstoned: 0,
|
|
749
|
+
filesRefusedStale: 0,
|
|
750
|
+
filesExcludedByPolicy: 0,
|
|
666
751
|
conflicts: 0,
|
|
667
752
|
conflictPaths: [],
|
|
668
753
|
aborted: true,
|
|
@@ -767,37 +852,214 @@ const isDirectInvocation = (() => {
|
|
|
767
852
|
}
|
|
768
853
|
})();
|
|
769
854
|
/**
|
|
770
|
-
*
|
|
771
|
-
*
|
|
772
|
-
*
|
|
773
|
-
*
|
|
774
|
-
*
|
|
775
|
-
*
|
|
855
|
+
* Route a changed relative path to the push target that owns it.
|
|
856
|
+
*
|
|
857
|
+
* - `companies/<slug>/...` → a single-company push for `<slug>` (the targeted
|
|
858
|
+
* pass per PRD decision — push only the changed company, not a full
|
|
859
|
+
* `--companies` fanout).
|
|
860
|
+
* - anything else under hqRoot → the personal target (a `--companies` push
|
|
861
|
+
* restricted to personal via the personal-vault scope; modeled here as the
|
|
862
|
+
* "personal" route the caller maps to the right argv).
|
|
863
|
+
*
|
|
864
|
+
* Returns `null` for paths the routing cannot attribute (defensive — the
|
|
865
|
+
* watcher's filter already drops excluded top-levels, so this is belt-and-
|
|
866
|
+
* suspenders for synthetic events).
|
|
867
|
+
*/
|
|
868
|
+
export function routeChangeToTarget(relPath) {
|
|
869
|
+
const norm = relPath.split(path.sep).join("/").replace(/^\.\//, "");
|
|
870
|
+
if (norm === "" || norm.startsWith(".."))
|
|
871
|
+
return null;
|
|
872
|
+
const segments = norm.split("/").filter((s) => s.length > 0);
|
|
873
|
+
if (segments.length === 0)
|
|
874
|
+
return null;
|
|
875
|
+
if (segments[0] === "companies") {
|
|
876
|
+
// companies/<slug>/... — need at least the slug segment to target.
|
|
877
|
+
if (segments.length < 2 || segments[1].length === 0)
|
|
878
|
+
return null;
|
|
879
|
+
return { kind: "company", slug: segments[1] };
|
|
880
|
+
}
|
|
881
|
+
return { kind: "personal" };
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Build the argv for a targeted push pass from a routed change. The push runs
|
|
885
|
+
* `--direction push` for just the routed target so a local edit propagates in
|
|
886
|
+
* seconds without a full fanout. Company routes use `--company <slug>`;
|
|
887
|
+
* personal routes use `--companies --direction push` (the personal-vault scope
|
|
888
|
+
* is resolved inside runRunner's fanout; skipUnchanged no-ops the company
|
|
889
|
+
* subtrees that didn't change). Inherits `--hq-root` / `--on-conflict` from
|
|
890
|
+
* the base argv. Pure helper, exported for unit testing the routing→argv map.
|
|
776
891
|
*/
|
|
777
|
-
export
|
|
892
|
+
export function buildTargetedPushArgv(route, baseArgv) {
|
|
893
|
+
const carried = carriedFlags(baseArgv);
|
|
894
|
+
if (route.kind === "company") {
|
|
895
|
+
return ["--company", route.slug, "--direction", "push", ...carried];
|
|
896
|
+
}
|
|
897
|
+
return ["--companies", "--direction", "push", ...carried];
|
|
898
|
+
}
|
|
899
|
+
export async function runRunnerWithLoop(argv, deps = {}) {
|
|
778
900
|
if (!argv.includes("--watch")) {
|
|
779
901
|
return runRunner(argv);
|
|
780
902
|
}
|
|
903
|
+
const sleep = deps.sleep ??
|
|
904
|
+
((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
905
|
+
const runPass = deps.runPass ?? ((passArgv) => runRunner(passArgv));
|
|
781
906
|
const pollIdx = argv.indexOf("--poll-remote-ms");
|
|
782
907
|
const pollMs = pollIdx >= 0 && argv[pollIdx + 1] ? Number(argv[pollIdx + 1]) : 600_000;
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
908
|
+
const eventPush = argv.includes("--event-push");
|
|
909
|
+
const hqIdx = argv.indexOf("--hq-root");
|
|
910
|
+
const hqRoot = hqIdx >= 0 && argv[hqIdx + 1] ? argv[hqIdx + 1] : DEFAULT_HQ_ROOT;
|
|
911
|
+
// Strip the loop-only flags before delegating: the parser inside runRunner
|
|
912
|
+
// accepts --watch/--poll-remote-ms/--event-push, but we don't want a per-
|
|
913
|
+
// iteration pass to think it's re-entering watch mode.
|
|
786
914
|
const passArgv = argv.filter((a, i) => {
|
|
787
915
|
if (a === "--watch")
|
|
788
916
|
return false;
|
|
789
917
|
if (a === "--poll-remote-ms")
|
|
790
918
|
return false;
|
|
919
|
+
if (a === "--event-push")
|
|
920
|
+
return false;
|
|
791
921
|
if (i > 0 && argv[i - 1] === "--poll-remote-ms")
|
|
792
922
|
return false;
|
|
793
923
|
return true;
|
|
794
924
|
});
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
925
|
+
// ---- shared in-flight guard ------------------------------------------
|
|
926
|
+
// The poll loop AND watcher-triggered targeted pushes funnel through this
|
|
927
|
+
// mutex so a watcher push never overlaps an in-flight pass (PRD AC). A
|
|
928
|
+
// trigger that arrives while a pass runs is collapsed by WatchPushDriver's
|
|
929
|
+
// own pending-while-pushing logic, then re-armed after the pass settles.
|
|
930
|
+
let inFlight = false;
|
|
931
|
+
let stopped = false;
|
|
932
|
+
const runGuarded = async (pass) => {
|
|
933
|
+
if (inFlight)
|
|
934
|
+
return "skipped";
|
|
935
|
+
inFlight = true;
|
|
936
|
+
try {
|
|
937
|
+
return await pass();
|
|
938
|
+
}
|
|
939
|
+
finally {
|
|
940
|
+
inFlight = false;
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
// ---- event-push wiring (Phase 1) -------------------------------------
|
|
944
|
+
let watcher = null;
|
|
945
|
+
let driver = null;
|
|
946
|
+
let detachSignal = null;
|
|
947
|
+
let lastChangedRel = null;
|
|
948
|
+
if (eventPush) {
|
|
949
|
+
const clock = deps.clock ?? systemClock;
|
|
950
|
+
const debounceMs = 2000;
|
|
951
|
+
const createWatcher = deps.createWatcher ??
|
|
952
|
+
((opts) => new TreeWatcher({
|
|
953
|
+
hqRoot: opts.hqRoot,
|
|
954
|
+
debounceMs: opts.debounceMs,
|
|
955
|
+
clock: opts.clock,
|
|
956
|
+
personalMode: true,
|
|
957
|
+
}));
|
|
958
|
+
watcher = createWatcher({ hqRoot, debounceMs, clock });
|
|
959
|
+
// The driver runs the targeted push when a debounced burst settles. Its
|
|
960
|
+
// concurrency guard collapses a trigger that lands mid-pass; we ALSO gate
|
|
961
|
+
// through `runGuarded` so a poll pass in flight is never overlapped.
|
|
962
|
+
driver = new WatchPushDriver({
|
|
963
|
+
debounceMs: 0, // TreeWatcher already debounces; the driver just guards.
|
|
964
|
+
clock,
|
|
965
|
+
push: async () => {
|
|
966
|
+
if (stopped)
|
|
967
|
+
return;
|
|
968
|
+
const rel = lastChangedRel;
|
|
969
|
+
const route = rel
|
|
970
|
+
? routeChangeToTarget(rel)
|
|
971
|
+
: { kind: "personal" };
|
|
972
|
+
if (!route)
|
|
973
|
+
return;
|
|
974
|
+
const targetedArgv = buildTargetedPushArgv(route, passArgv);
|
|
975
|
+
await runGuarded(() => runPass(targetedArgv));
|
|
976
|
+
},
|
|
977
|
+
});
|
|
978
|
+
// A debounced TreeWatcher 'changed' signal feeds the driver, which fires
|
|
979
|
+
// the targeted push after its own (zero) window — i.e. immediately, but
|
|
980
|
+
// still serialized behind any in-flight pass. A path-aware watcher passes
|
|
981
|
+
// the changed relative path so the push targets just its owning company;
|
|
982
|
+
// the bare-signal TreeWatcher leaves it null → personal-vault route.
|
|
983
|
+
watcher.onChange((changedRelPath) => {
|
|
984
|
+
if (stopped)
|
|
985
|
+
return;
|
|
986
|
+
lastChangedRel = changedRelPath ?? null;
|
|
987
|
+
driver?.notifyChange();
|
|
988
|
+
});
|
|
989
|
+
watcher.start();
|
|
990
|
+
}
|
|
991
|
+
// ---- clean shutdown --------------------------------------------------
|
|
992
|
+
// SIGTERM (menubar stop_daemon) / SIGINT must tear down BOTH the watcher and
|
|
993
|
+
// the poll loop with no leaked timers or fds. We flip `stopped`, dispose the
|
|
994
|
+
// watcher + driver, and let the poll loop observe `stopped` on its next tick.
|
|
995
|
+
let resolveStopped = null;
|
|
996
|
+
const stoppedSignal = new Promise((resolve) => {
|
|
997
|
+
resolveStopped = resolve;
|
|
998
|
+
});
|
|
999
|
+
const shutdown = () => {
|
|
1000
|
+
if (stopped)
|
|
1001
|
+
return;
|
|
1002
|
+
stopped = true;
|
|
1003
|
+
try {
|
|
1004
|
+
driver?.dispose();
|
|
1005
|
+
}
|
|
1006
|
+
catch {
|
|
1007
|
+
/* ignore */
|
|
1008
|
+
}
|
|
1009
|
+
try {
|
|
1010
|
+
watcher?.dispose();
|
|
1011
|
+
}
|
|
1012
|
+
catch {
|
|
1013
|
+
/* ignore */
|
|
1014
|
+
}
|
|
1015
|
+
resolveStopped?.();
|
|
1016
|
+
};
|
|
1017
|
+
const onShutdownSignal = deps.onShutdownSignal ??
|
|
1018
|
+
((handler) => {
|
|
1019
|
+
const wrapped = () => handler();
|
|
1020
|
+
process.on("SIGTERM", wrapped);
|
|
1021
|
+
process.on("SIGINT", wrapped);
|
|
1022
|
+
return () => {
|
|
1023
|
+
process.off("SIGTERM", wrapped);
|
|
1024
|
+
process.off("SIGINT", wrapped);
|
|
1025
|
+
};
|
|
1026
|
+
});
|
|
1027
|
+
detachSignal = onShutdownSignal(shutdown);
|
|
1028
|
+
try {
|
|
1029
|
+
while (!stopped) {
|
|
1030
|
+
const result = await runGuarded(() => runPass(passArgv));
|
|
1031
|
+
// A poll pass that was skipped because a watcher push held the guard is
|
|
1032
|
+
// benign — the next iteration retries after the poll interval.
|
|
1033
|
+
if (typeof result === "number" && result !== 0) {
|
|
1034
|
+
return result;
|
|
1035
|
+
}
|
|
1036
|
+
// Sleep the poll interval, but wake early on shutdown so SIGTERM stops
|
|
1037
|
+
// the loop promptly instead of waiting out a 10-minute cycle.
|
|
1038
|
+
await Promise.race([sleep(pollMs), stoppedSignal]);
|
|
1039
|
+
}
|
|
1040
|
+
return 0;
|
|
1041
|
+
}
|
|
1042
|
+
finally {
|
|
1043
|
+
shutdown();
|
|
1044
|
+
detachSignal?.();
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Extract the `--hq-root` / `--on-conflict` pair-flags from a base argv so a
|
|
1049
|
+
* re-targeted push pass inherits the same root and conflict policy. Pure
|
|
1050
|
+
* helper used by the event-push targeted-push composer.
|
|
1051
|
+
*/
|
|
1052
|
+
function carriedFlags(baseArgv) {
|
|
1053
|
+
const carried = [];
|
|
1054
|
+
for (let i = 0; i < baseArgv.length; i++) {
|
|
1055
|
+
const a = baseArgv[i];
|
|
1056
|
+
if (a === "--hq-root" || a === "--on-conflict") {
|
|
1057
|
+
carried.push(a);
|
|
1058
|
+
if (baseArgv[i + 1] !== undefined)
|
|
1059
|
+
carried.push(baseArgv[++i]);
|
|
1060
|
+
}
|
|
800
1061
|
}
|
|
1062
|
+
return carried;
|
|
801
1063
|
}
|
|
802
1064
|
if (isDirectInvocation) {
|
|
803
1065
|
runRunnerWithLoop(process.argv.slice(2))
|