@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
package/src/bin/sync-runner.ts
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.
|
|
@@ -84,6 +88,12 @@ import type { ShareOptions, ShareResult } from "../cli/share.js";
|
|
|
84
88
|
import type { ConflictStrategy } from "../cli/conflict.js";
|
|
85
89
|
import type { UploadAuthor } from "../s3.js";
|
|
86
90
|
import { collectAndSendTelemetry } from "../telemetry.js";
|
|
91
|
+
import {
|
|
92
|
+
TreeWatcher,
|
|
93
|
+
WatchPushDriver,
|
|
94
|
+
systemClock,
|
|
95
|
+
type Clock,
|
|
96
|
+
} from "../watcher.js";
|
|
87
97
|
|
|
88
98
|
/**
|
|
89
99
|
* Sync direction for a run.
|
|
@@ -122,21 +132,24 @@ const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
|
|
|
122
132
|
|
|
123
133
|
/**
|
|
124
134
|
* Delete-propagation policy honored by the push leg of bidirectional sync.
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
135
|
+
*
|
|
136
|
+
* Default `"currency-gated"` in 5.25 — flipped from `"owned-only"` after
|
|
137
|
+
* one machine (Indigo / corey) ran the 5.24 code path through real syncs
|
|
138
|
+
* for a week without surfacing surprise behavior. Currency-gated does a
|
|
139
|
+
* per-file ETag HEAD before propagating any local-delete to S3: if the
|
|
140
|
+
* remote object's current ETag no longer matches the journal's last-
|
|
141
|
+
* recorded one, the delete is refused and the next pull leg re-pulls the
|
|
142
|
+
* file via the standard 3-way merge path. This is strictly safer than
|
|
143
|
+
* `owned-only` (which propagates any local-delete the journal can prove
|
|
144
|
+
* came from this device) — the only delete-class that changes behavior
|
|
145
|
+
* is "deleted locally + modified remotely by another device", which
|
|
146
|
+
* previously destroyed remote work and now becomes a pull-and-conflict.
|
|
133
147
|
*
|
|
134
148
|
* Env override `HQ_SYNC_DELETE_POLICY=owned-only|all|currency-gated` is
|
|
135
|
-
* also the rollback knob —
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* recommended default.
|
|
149
|
+
* also the rollback knob — anyone surprised by 5.25's flip can revert
|
|
150
|
+
* to `owned-only` without redeploying. `all` is the unsafe-mirror mode
|
|
151
|
+
* previously used by the runner pre-5.20 — included only as an
|
|
152
|
+
* emergency reconcile lever, not a recommended default.
|
|
140
153
|
*/
|
|
141
154
|
export type DeletePropagationPolicy = "currency-gated" | "owned-only" | "all";
|
|
142
155
|
|
|
@@ -145,7 +158,33 @@ export function resolveDeletePolicy(): DeletePropagationPolicy {
|
|
|
145
158
|
if (env === "owned-only" || env === "all" || env === "currency-gated") {
|
|
146
159
|
return env;
|
|
147
160
|
}
|
|
148
|
-
return "
|
|
161
|
+
return "currency-gated";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Resolve whether to skip the personal target in a `--companies` fanout.
|
|
166
|
+
*
|
|
167
|
+
* Two inputs combine: the `--skip-personal` CLI flag (parsed into
|
|
168
|
+
* `ParsedArgs.skipPersonal`) and the `HQ_SYNC_SKIP_PERSONAL` env var. Either
|
|
169
|
+
* being truthy skips the personal target — flag wins on conflict (CLI
|
|
170
|
+
* flag is the explicit-for-this-invocation knob, env is the persistent
|
|
171
|
+
* default usually set by the menubar in the spawned child process).
|
|
172
|
+
*
|
|
173
|
+
* Env truthy values: `1`, `true`, `yes` (case-insensitive). Anything else
|
|
174
|
+
* (including missing) is treated as falsy — same shape as classic
|
|
175
|
+
* Unix opt-in env conventions; conservative to avoid surprising opt-outs.
|
|
176
|
+
*
|
|
177
|
+
* Use case: the menubar app exposes a "Sync personal vault" toggle in
|
|
178
|
+
* Settings (default ON, matching the auto-provisioning UX). When the user
|
|
179
|
+
* flips it off, the menubar spawns `hq sync` with this env set so the
|
|
180
|
+
* fanout drops the personal target before walking the user's entire HQ
|
|
181
|
+
* tree (a sync that would otherwise scan thousands of files, including
|
|
182
|
+
* the new personal-vault default exclusions, just to do nothing useful).
|
|
183
|
+
*/
|
|
184
|
+
export function resolveSkipPersonal(flag: boolean): boolean {
|
|
185
|
+
if (flag) return true;
|
|
186
|
+
const env = (process.env.HQ_SYNC_SKIP_PERSONAL ?? "").toLowerCase();
|
|
187
|
+
return env === "1" || env === "true" || env === "yes";
|
|
149
188
|
}
|
|
150
189
|
|
|
151
190
|
// Personal-vault scope (exclusion list + path computer) lives in
|
|
@@ -197,12 +236,19 @@ export type RunnerEvent =
|
|
|
197
236
|
/**
|
|
198
237
|
* Upload counters. Always emitted (0 when the run was pull-only) so
|
|
199
238
|
* downstream consumers don't need to conditionally read the field.
|
|
200
|
-
* Tauri's `SyncCompleteEvent` ignores extra fields today; adding them
|
|
201
|
-
* to the Rust struct is a follow-up when the UI needs to surface push
|
|
202
|
-
* totals.
|
|
203
239
|
*/
|
|
204
240
|
filesUploaded: number;
|
|
205
241
|
bytesUploaded: number;
|
|
242
|
+
/**
|
|
243
|
+
* Push-side counters added in 5.25. Always emitted as numbers (0
|
|
244
|
+
* when no push leg ran). Tauri's `SyncCompleteEvent` carries them
|
|
245
|
+
* as Option<u32> for back-compat with <5.25 engines that don't
|
|
246
|
+
* include them; structural-typing-wise, the union just adds
|
|
247
|
+
* properties on top of `SyncResult`.
|
|
248
|
+
*/
|
|
249
|
+
filesTombstoned: number;
|
|
250
|
+
filesRefusedStale: number;
|
|
251
|
+
filesExcludedByPolicy: number;
|
|
206
252
|
} & SyncResult)
|
|
207
253
|
| {
|
|
208
254
|
type: "all-complete";
|
|
@@ -402,6 +448,22 @@ interface ParsedArgs {
|
|
|
402
448
|
watch: boolean;
|
|
403
449
|
/** Auto-sync (Beta): ms between remote-pull passes. Required when watch=true. */
|
|
404
450
|
pollRemoteMs?: number;
|
|
451
|
+
/**
|
|
452
|
+
* Event-driven push (Phase 1). When set (and `--watch` is on), the runner
|
|
453
|
+
* starts a {@link TreeWatcher} alongside the poll loop and pushes a targeted
|
|
454
|
+
* company/subtree within the debounce window of a local edit — instead of
|
|
455
|
+
* waiting up to a full `--poll-remote-ms` cycle. Gated OFF by default; the
|
|
456
|
+
* menubar passes it only for `@getindigo.ai` identities (see PRD decision).
|
|
457
|
+
* No-op without `--watch` (the one-shot path has nothing to keep alive).
|
|
458
|
+
*/
|
|
459
|
+
eventPush: boolean;
|
|
460
|
+
/**
|
|
461
|
+
* Drop the personal target from the fanout. Combined with the
|
|
462
|
+
* `HQ_SYNC_SKIP_PERSONAL` env var by `resolveSkipPersonal()` — either
|
|
463
|
+
* truthy disables personal sync for this run. No-op outside `--companies`
|
|
464
|
+
* mode (single-company runs never visit the personal target).
|
|
465
|
+
*/
|
|
466
|
+
skipPersonal: boolean;
|
|
405
467
|
}
|
|
406
468
|
|
|
407
469
|
function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
@@ -412,6 +474,8 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
412
474
|
let direction: Direction = "pull";
|
|
413
475
|
let watch = false;
|
|
414
476
|
let pollRemoteMs: number | undefined;
|
|
477
|
+
let skipPersonal = false;
|
|
478
|
+
let eventPush = false;
|
|
415
479
|
|
|
416
480
|
for (let i = 0; i < argv.length; i++) {
|
|
417
481
|
const arg = argv[i];
|
|
@@ -465,6 +529,18 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
465
529
|
case "--json":
|
|
466
530
|
// Accepted but ignored — ndjson is the only output mode.
|
|
467
531
|
break;
|
|
532
|
+
case "--skip-personal":
|
|
533
|
+
// Drop the personal target from the fanout. No-op outside
|
|
534
|
+
// --companies mode. Combined with HQ_SYNC_SKIP_PERSONAL env via
|
|
535
|
+
// resolveSkipPersonal().
|
|
536
|
+
skipPersonal = true;
|
|
537
|
+
break;
|
|
538
|
+
case "--event-push":
|
|
539
|
+
// Phase 1 event-driven push enable flag. Requires --watch (validated
|
|
540
|
+
// below). Gated OFF by default; the menubar only passes it for
|
|
541
|
+
// @getindigo.ai identities for the first release.
|
|
542
|
+
eventPush = true;
|
|
543
|
+
break;
|
|
468
544
|
default:
|
|
469
545
|
return { error: `Unknown argument: ${arg}` };
|
|
470
546
|
}
|
|
@@ -479,8 +555,21 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
479
555
|
if (pollRemoteMs !== undefined && !watch) {
|
|
480
556
|
return { error: "--poll-remote-ms requires --watch" };
|
|
481
557
|
}
|
|
558
|
+
if (eventPush && !watch) {
|
|
559
|
+
return { error: "--event-push requires --watch" };
|
|
560
|
+
}
|
|
482
561
|
|
|
483
|
-
return {
|
|
562
|
+
return {
|
|
563
|
+
companies,
|
|
564
|
+
company,
|
|
565
|
+
onConflict,
|
|
566
|
+
hqRoot,
|
|
567
|
+
direction,
|
|
568
|
+
watch,
|
|
569
|
+
pollRemoteMs,
|
|
570
|
+
skipPersonal,
|
|
571
|
+
eventPush,
|
|
572
|
+
};
|
|
484
573
|
}
|
|
485
574
|
|
|
486
575
|
// ---------------------------------------------------------------------------
|
|
@@ -698,7 +787,15 @@ export async function runRunner(
|
|
|
698
787
|
plan.push({ uid: m.companyUid, slug, ...(name ? { name } : {}) });
|
|
699
788
|
}
|
|
700
789
|
|
|
701
|
-
if (parsed.companies) {
|
|
790
|
+
if (parsed.companies && !resolveSkipPersonal(parsed.skipPersonal)) {
|
|
791
|
+
// Personal-target fanout slot. Skipped entirely when --skip-personal
|
|
792
|
+
// (or HQ_SYNC_SKIP_PERSONAL=1) is set — see resolveSkipPersonal doc for
|
|
793
|
+
// the rationale (menubar opt-out for users who only want company sync).
|
|
794
|
+
// When skipped, the fanout-plan event below carries only company
|
|
795
|
+
// memberships and no "personal" slug; downstream consumers (menubar
|
|
796
|
+
// workspaces row, status surfaces) should already tolerate that
|
|
797
|
+
// shape since pre-5.25 fanout often had it (a user with no person
|
|
798
|
+
// entity yet, or before the canonical-person-entity machinery landed).
|
|
702
799
|
const persons = await client.entity.listByType("person");
|
|
703
800
|
const pick = pickCanonicalPersonEntity(persons);
|
|
704
801
|
if (pick?.bucketName) {
|
|
@@ -829,6 +926,7 @@ export async function runRunner(
|
|
|
829
926
|
filesDeleted: 0,
|
|
830
927
|
filesTombstoned: 0,
|
|
831
928
|
filesRefusedStale: 0,
|
|
929
|
+
filesExcludedByPolicy: 0,
|
|
832
930
|
conflictPaths: [],
|
|
833
931
|
aborted: false,
|
|
834
932
|
};
|
|
@@ -930,6 +1028,17 @@ export async function runRunner(
|
|
|
930
1028
|
filesUploaded: pushResult.filesUploaded,
|
|
931
1029
|
bytesUploaded: pushResult.bytesUploaded,
|
|
932
1030
|
filesSkipped: pullResult.filesSkipped + pushResult.filesSkipped,
|
|
1031
|
+
// Push-side counters surfaced on `complete` so the menubar's
|
|
1032
|
+
// `SyncCompleteEvent` (which carries them as Option<u32> for
|
|
1033
|
+
// back-compat with pre-5.25 engines) can render the new totals.
|
|
1034
|
+
// Always emitted as numbers (0 when no push leg ran) so Rust's
|
|
1035
|
+
// serde decodes them as `Some(0)` rather than `None` — distinct
|
|
1036
|
+
// from the legacy-engine `None` and useful when the UI wants to
|
|
1037
|
+
// distinguish "engine ran, nothing tombstoned" from "engine
|
|
1038
|
+
// didn't report".
|
|
1039
|
+
filesTombstoned: pushResult.filesTombstoned,
|
|
1040
|
+
filesRefusedStale: pushResult.filesRefusedStale,
|
|
1041
|
+
filesExcludedByPolicy: pushResult.filesExcludedByPolicy,
|
|
933
1042
|
// Sourced from the merged path list so push-side conflicts are
|
|
934
1043
|
// counted too — `ShareResult` doesn't expose a numeric counter,
|
|
935
1044
|
// and using `pullResult.conflicts` alone silently dropped any
|
|
@@ -967,6 +1076,13 @@ export async function runRunner(
|
|
|
967
1076
|
filesUploaded: state.filesUploaded,
|
|
968
1077
|
bytesUploaded: state.bytesUploaded,
|
|
969
1078
|
filesSkipped: 0,
|
|
1079
|
+
// Mid-flight throw: we have no clean ShareResult to read these
|
|
1080
|
+
// from. Report 0 so the event shape stays stable; the partial
|
|
1081
|
+
// counts above already reflect what actually moved before the
|
|
1082
|
+
// throw.
|
|
1083
|
+
filesTombstoned: 0,
|
|
1084
|
+
filesRefusedStale: 0,
|
|
1085
|
+
filesExcludedByPolicy: 0,
|
|
970
1086
|
conflicts: 0,
|
|
971
1087
|
conflictPaths: [],
|
|
972
1088
|
aborted: true,
|
|
@@ -1088,29 +1204,290 @@ const isDirectInvocation = (() => {
|
|
|
1088
1204
|
* exit 0 today and so will retry — acceptable noise for the beta; deal with
|
|
1089
1205
|
* it via a richer return shape if it shows up in Sentry.
|
|
1090
1206
|
*/
|
|
1091
|
-
|
|
1207
|
+
/**
|
|
1208
|
+
* Test/event-driven seam (US-001).
|
|
1209
|
+
*
|
|
1210
|
+
* `runRunnerWithLoop` performs an unbounded poll loop in production. To make
|
|
1211
|
+
* the loop deterministically testable (US-003 wires the event-driven watcher
|
|
1212
|
+
* into this same loop), the inter-pass sleep is injectable via `deps.sleep`.
|
|
1213
|
+
* The default uses the host timer and preserves exact production behavior.
|
|
1214
|
+
*
|
|
1215
|
+
* A test (or US-003's wiring) injects a fake sleep that resolves immediately
|
|
1216
|
+
* and/or coordinates with the {@link WatchPushDriver} seam in `../watcher.js`,
|
|
1217
|
+
* so the loop can be exercised without a real 10-minute wait.
|
|
1218
|
+
*/
|
|
1219
|
+
export interface RunnerLoopDeps {
|
|
1220
|
+
/** Sleep `ms` between passes. Default: host setTimeout. */
|
|
1221
|
+
sleep?: (ms: number) => Promise<void>;
|
|
1222
|
+
/**
|
|
1223
|
+
* Run a single sync pass. Defaults to {@link runRunner}. Injected by tests
|
|
1224
|
+
* (and the event-push wiring) so the poll loop and the watcher-triggered
|
|
1225
|
+
* targeted push share one seam and one in-flight guard. The default ignores
|
|
1226
|
+
* `deps` and forwards just the argv to `runRunner`.
|
|
1227
|
+
*/
|
|
1228
|
+
runPass?: (passArgv: string[]) => Promise<number>;
|
|
1229
|
+
/**
|
|
1230
|
+
* Clock seam for the event-push watcher's debounce window. Defaults to
|
|
1231
|
+
* {@link systemClock}; tests inject a `FakeClock` to advance the window
|
|
1232
|
+
* deterministically. Only consulted when `--event-push` is on.
|
|
1233
|
+
*/
|
|
1234
|
+
clock?: Clock;
|
|
1235
|
+
/**
|
|
1236
|
+
* Factory for the file watcher used in event-push mode. Defaults to a real
|
|
1237
|
+
* {@link TreeWatcher} over `hqRoot`. Tests inject a stub exposing the same
|
|
1238
|
+
* `onChange`/`start`/`stop`/`dispose` surface so no real chokidar runs.
|
|
1239
|
+
*/
|
|
1240
|
+
createWatcher?: (opts: {
|
|
1241
|
+
hqRoot: string;
|
|
1242
|
+
debounceMs: number;
|
|
1243
|
+
clock: Clock;
|
|
1244
|
+
}) => WatcherSurface;
|
|
1245
|
+
/**
|
|
1246
|
+
* Register a one-shot shutdown signal handler. Defaults to listening for
|
|
1247
|
+
* SIGTERM/SIGINT on `process`. Tests inject a controllable trigger to assert
|
|
1248
|
+
* clean teardown without sending real signals. The returned fn detaches the
|
|
1249
|
+
* handler (called during teardown so tests don't leak listeners).
|
|
1250
|
+
*/
|
|
1251
|
+
onShutdownSignal?: (handler: () => void) => () => void;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* The minimal watcher surface the loop drives. {@link TreeWatcher} satisfies
|
|
1256
|
+
* it; tests inject a lighter stub. Kept narrow so the loop never reaches past
|
|
1257
|
+
* the lifecycle + change-subscription contract.
|
|
1258
|
+
*
|
|
1259
|
+
* `onChange`'s listener receives an OPTIONAL changed relative path. The real
|
|
1260
|
+
* {@link TreeWatcher} emits a bare debounced signal (no path) — in that case
|
|
1261
|
+
* the loop routes the targeted push to the personal vault. A path-aware
|
|
1262
|
+
* watcher (or a test stub) can pass the changed `companies/<slug>/...`
|
|
1263
|
+
* relative path so the loop targets just that company.
|
|
1264
|
+
*/
|
|
1265
|
+
export interface WatcherSurface {
|
|
1266
|
+
onChange(listener: (changedRelPath?: string) => void): () => void;
|
|
1267
|
+
start(): void;
|
|
1268
|
+
stop(): void;
|
|
1269
|
+
dispose(): void;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Route a changed relative path to the push target that owns it.
|
|
1274
|
+
*
|
|
1275
|
+
* - `companies/<slug>/...` → a single-company push for `<slug>` (the targeted
|
|
1276
|
+
* pass per PRD decision — push only the changed company, not a full
|
|
1277
|
+
* `--companies` fanout).
|
|
1278
|
+
* - anything else under hqRoot → the personal target (a `--companies` push
|
|
1279
|
+
* restricted to personal via the personal-vault scope; modeled here as the
|
|
1280
|
+
* "personal" route the caller maps to the right argv).
|
|
1281
|
+
*
|
|
1282
|
+
* Returns `null` for paths the routing cannot attribute (defensive — the
|
|
1283
|
+
* watcher's filter already drops excluded top-levels, so this is belt-and-
|
|
1284
|
+
* suspenders for synthetic events).
|
|
1285
|
+
*/
|
|
1286
|
+
export function routeChangeToTarget(
|
|
1287
|
+
relPath: string,
|
|
1288
|
+
): { kind: "company"; slug: string } | { kind: "personal" } | null {
|
|
1289
|
+
const norm = relPath.split(path.sep).join("/").replace(/^\.\//, "");
|
|
1290
|
+
if (norm === "" || norm.startsWith("..")) return null;
|
|
1291
|
+
const segments = norm.split("/").filter((s) => s.length > 0);
|
|
1292
|
+
if (segments.length === 0) return null;
|
|
1293
|
+
if (segments[0] === "companies") {
|
|
1294
|
+
// companies/<slug>/... — need at least the slug segment to target.
|
|
1295
|
+
if (segments.length < 2 || segments[1].length === 0) return null;
|
|
1296
|
+
return { kind: "company", slug: segments[1] };
|
|
1297
|
+
}
|
|
1298
|
+
return { kind: "personal" };
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* Build the argv for a targeted push pass from a routed change. The push runs
|
|
1303
|
+
* `--direction push` for just the routed target so a local edit propagates in
|
|
1304
|
+
* seconds without a full fanout. Company routes use `--company <slug>`;
|
|
1305
|
+
* personal routes use `--companies --direction push` (the personal-vault scope
|
|
1306
|
+
* is resolved inside runRunner's fanout; skipUnchanged no-ops the company
|
|
1307
|
+
* subtrees that didn't change). Inherits `--hq-root` / `--on-conflict` from
|
|
1308
|
+
* the base argv. Pure helper, exported for unit testing the routing→argv map.
|
|
1309
|
+
*/
|
|
1310
|
+
export function buildTargetedPushArgv(
|
|
1311
|
+
route: { kind: "company"; slug: string } | { kind: "personal" },
|
|
1312
|
+
baseArgv: string[],
|
|
1313
|
+
): string[] {
|
|
1314
|
+
const carried = carriedFlags(baseArgv);
|
|
1315
|
+
if (route.kind === "company") {
|
|
1316
|
+
return ["--company", route.slug, "--direction", "push", ...carried];
|
|
1317
|
+
}
|
|
1318
|
+
return ["--companies", "--direction", "push", ...carried];
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
export async function runRunnerWithLoop(
|
|
1322
|
+
argv: string[],
|
|
1323
|
+
deps: RunnerLoopDeps = {},
|
|
1324
|
+
): Promise<number> {
|
|
1092
1325
|
if (!argv.includes("--watch")) {
|
|
1093
1326
|
return runRunner(argv);
|
|
1094
1327
|
}
|
|
1328
|
+
const sleep =
|
|
1329
|
+
deps.sleep ??
|
|
1330
|
+
((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
1331
|
+
const runPass = deps.runPass ?? ((passArgv: string[]) => runRunner(passArgv));
|
|
1095
1332
|
const pollIdx = argv.indexOf("--poll-remote-ms");
|
|
1096
1333
|
const pollMs =
|
|
1097
1334
|
pollIdx >= 0 && argv[pollIdx + 1] ? Number(argv[pollIdx + 1]) : 600_000;
|
|
1335
|
+
const eventPush = argv.includes("--event-push");
|
|
1336
|
+
const hqIdx = argv.indexOf("--hq-root");
|
|
1337
|
+
const hqRoot =
|
|
1338
|
+
hqIdx >= 0 && argv[hqIdx + 1] ? argv[hqIdx + 1] : DEFAULT_HQ_ROOT;
|
|
1098
1339
|
|
|
1099
|
-
// Strip
|
|
1100
|
-
//
|
|
1101
|
-
// re-entering watch mode
|
|
1340
|
+
// Strip the loop-only flags before delegating: the parser inside runRunner
|
|
1341
|
+
// accepts --watch/--poll-remote-ms/--event-push, but we don't want a per-
|
|
1342
|
+
// iteration pass to think it's re-entering watch mode.
|
|
1102
1343
|
const passArgv = argv.filter((a, i) => {
|
|
1103
1344
|
if (a === "--watch") return false;
|
|
1104
1345
|
if (a === "--poll-remote-ms") return false;
|
|
1346
|
+
if (a === "--event-push") return false;
|
|
1105
1347
|
if (i > 0 && argv[i - 1] === "--poll-remote-ms") return false;
|
|
1106
1348
|
return true;
|
|
1107
1349
|
});
|
|
1108
1350
|
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1351
|
+
// ---- shared in-flight guard ------------------------------------------
|
|
1352
|
+
// The poll loop AND watcher-triggered targeted pushes funnel through this
|
|
1353
|
+
// mutex so a watcher push never overlaps an in-flight pass (PRD AC). A
|
|
1354
|
+
// trigger that arrives while a pass runs is collapsed by WatchPushDriver's
|
|
1355
|
+
// own pending-while-pushing logic, then re-armed after the pass settles.
|
|
1356
|
+
let inFlight = false;
|
|
1357
|
+
let stopped = false;
|
|
1358
|
+
const runGuarded = async (
|
|
1359
|
+
pass: () => Promise<number>,
|
|
1360
|
+
): Promise<number | "skipped"> => {
|
|
1361
|
+
if (inFlight) return "skipped";
|
|
1362
|
+
inFlight = true;
|
|
1363
|
+
try {
|
|
1364
|
+
return await pass();
|
|
1365
|
+
} finally {
|
|
1366
|
+
inFlight = false;
|
|
1367
|
+
}
|
|
1368
|
+
};
|
|
1369
|
+
|
|
1370
|
+
// ---- event-push wiring (Phase 1) -------------------------------------
|
|
1371
|
+
let watcher: WatcherSurface | null = null;
|
|
1372
|
+
let driver: WatchPushDriver | null = null;
|
|
1373
|
+
let detachSignal: (() => void) | null = null;
|
|
1374
|
+
let lastChangedRel: string | null = null;
|
|
1375
|
+
|
|
1376
|
+
if (eventPush) {
|
|
1377
|
+
const clock = deps.clock ?? systemClock;
|
|
1378
|
+
const debounceMs = 2000;
|
|
1379
|
+
const createWatcher =
|
|
1380
|
+
deps.createWatcher ??
|
|
1381
|
+
((opts) =>
|
|
1382
|
+
new TreeWatcher({
|
|
1383
|
+
hqRoot: opts.hqRoot,
|
|
1384
|
+
debounceMs: opts.debounceMs,
|
|
1385
|
+
clock: opts.clock,
|
|
1386
|
+
personalMode: true,
|
|
1387
|
+
}));
|
|
1388
|
+
watcher = createWatcher({ hqRoot, debounceMs, clock });
|
|
1389
|
+
|
|
1390
|
+
// The driver runs the targeted push when a debounced burst settles. Its
|
|
1391
|
+
// concurrency guard collapses a trigger that lands mid-pass; we ALSO gate
|
|
1392
|
+
// through `runGuarded` so a poll pass in flight is never overlapped.
|
|
1393
|
+
driver = new WatchPushDriver({
|
|
1394
|
+
debounceMs: 0, // TreeWatcher already debounces; the driver just guards.
|
|
1395
|
+
clock,
|
|
1396
|
+
push: async () => {
|
|
1397
|
+
if (stopped) return;
|
|
1398
|
+
const rel = lastChangedRel;
|
|
1399
|
+
const route = rel
|
|
1400
|
+
? routeChangeToTarget(rel)
|
|
1401
|
+
: { kind: "personal" as const };
|
|
1402
|
+
if (!route) return;
|
|
1403
|
+
const targetedArgv = buildTargetedPushArgv(route, passArgv);
|
|
1404
|
+
await runGuarded(() => runPass(targetedArgv));
|
|
1405
|
+
},
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
// A debounced TreeWatcher 'changed' signal feeds the driver, which fires
|
|
1409
|
+
// the targeted push after its own (zero) window — i.e. immediately, but
|
|
1410
|
+
// still serialized behind any in-flight pass. A path-aware watcher passes
|
|
1411
|
+
// the changed relative path so the push targets just its owning company;
|
|
1412
|
+
// the bare-signal TreeWatcher leaves it null → personal-vault route.
|
|
1413
|
+
watcher.onChange((changedRelPath) => {
|
|
1414
|
+
if (stopped) return;
|
|
1415
|
+
lastChangedRel = changedRelPath ?? null;
|
|
1416
|
+
driver?.notifyChange();
|
|
1417
|
+
});
|
|
1418
|
+
watcher.start();
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// ---- clean shutdown --------------------------------------------------
|
|
1422
|
+
// SIGTERM (menubar stop_daemon) / SIGINT must tear down BOTH the watcher and
|
|
1423
|
+
// the poll loop with no leaked timers or fds. We flip `stopped`, dispose the
|
|
1424
|
+
// watcher + driver, and let the poll loop observe `stopped` on its next tick.
|
|
1425
|
+
let resolveStopped: (() => void) | null = null;
|
|
1426
|
+
const stoppedSignal = new Promise<void>((resolve) => {
|
|
1427
|
+
resolveStopped = resolve;
|
|
1428
|
+
});
|
|
1429
|
+
const shutdown = (): void => {
|
|
1430
|
+
if (stopped) return;
|
|
1431
|
+
stopped = true;
|
|
1432
|
+
try {
|
|
1433
|
+
driver?.dispose();
|
|
1434
|
+
} catch {
|
|
1435
|
+
/* ignore */
|
|
1436
|
+
}
|
|
1437
|
+
try {
|
|
1438
|
+
watcher?.dispose();
|
|
1439
|
+
} catch {
|
|
1440
|
+
/* ignore */
|
|
1441
|
+
}
|
|
1442
|
+
resolveStopped?.();
|
|
1443
|
+
};
|
|
1444
|
+
const onShutdownSignal =
|
|
1445
|
+
deps.onShutdownSignal ??
|
|
1446
|
+
((handler: () => void) => {
|
|
1447
|
+
const wrapped = () => handler();
|
|
1448
|
+
process.on("SIGTERM", wrapped);
|
|
1449
|
+
process.on("SIGINT", wrapped);
|
|
1450
|
+
return () => {
|
|
1451
|
+
process.off("SIGTERM", wrapped);
|
|
1452
|
+
process.off("SIGINT", wrapped);
|
|
1453
|
+
};
|
|
1454
|
+
});
|
|
1455
|
+
detachSignal = onShutdownSignal(shutdown);
|
|
1456
|
+
|
|
1457
|
+
try {
|
|
1458
|
+
while (!stopped) {
|
|
1459
|
+
const result = await runGuarded(() => runPass(passArgv));
|
|
1460
|
+
// A poll pass that was skipped because a watcher push held the guard is
|
|
1461
|
+
// benign — the next iteration retries after the poll interval.
|
|
1462
|
+
if (typeof result === "number" && result !== 0) {
|
|
1463
|
+
return result;
|
|
1464
|
+
}
|
|
1465
|
+
// Sleep the poll interval, but wake early on shutdown so SIGTERM stops
|
|
1466
|
+
// the loop promptly instead of waiting out a 10-minute cycle.
|
|
1467
|
+
await Promise.race([sleep(pollMs), stoppedSignal]);
|
|
1468
|
+
}
|
|
1469
|
+
return 0;
|
|
1470
|
+
} finally {
|
|
1471
|
+
shutdown();
|
|
1472
|
+
detachSignal?.();
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/**
|
|
1477
|
+
* Extract the `--hq-root` / `--on-conflict` pair-flags from a base argv so a
|
|
1478
|
+
* re-targeted push pass inherits the same root and conflict policy. Pure
|
|
1479
|
+
* helper used by the event-push targeted-push composer.
|
|
1480
|
+
*/
|
|
1481
|
+
function carriedFlags(baseArgv: string[]): string[] {
|
|
1482
|
+
const carried: string[] = [];
|
|
1483
|
+
for (let i = 0; i < baseArgv.length; i++) {
|
|
1484
|
+
const a = baseArgv[i];
|
|
1485
|
+
if (a === "--hq-root" || a === "--on-conflict") {
|
|
1486
|
+
carried.push(a);
|
|
1487
|
+
if (baseArgv[i + 1] !== undefined) carried.push(baseArgv[++i]);
|
|
1488
|
+
}
|
|
1113
1489
|
}
|
|
1490
|
+
return carried;
|
|
1114
1491
|
}
|
|
1115
1492
|
|
|
1116
1493
|
if (isDirectInvocation) {
|
package/src/cli/share.test.ts
CHANGED
|
@@ -595,9 +595,14 @@ describe("share", () => {
|
|
|
595
595
|
vaultConfig: mockConfig,
|
|
596
596
|
hqRoot: tmpDir,
|
|
597
597
|
onEvent: (e) => {
|
|
598
|
-
// Only file-level events carry `.path`. The Stage-1 `plan` event
|
|
599
|
-
//
|
|
600
|
-
|
|
598
|
+
// Only file-level events carry `.path`. The Stage-1 `plan` event +
|
|
599
|
+
// the new-files event + the personal-vault-out-of-policy summary
|
|
600
|
+
// event are surfaced separately and tested in their own blocks.
|
|
601
|
+
if (
|
|
602
|
+
e.type === "plan" ||
|
|
603
|
+
e.type === "new-files" ||
|
|
604
|
+
e.type === "personal-vault-out-of-policy"
|
|
605
|
+
) return;
|
|
601
606
|
events.push({
|
|
602
607
|
type: e.type,
|
|
603
608
|
path: e.path,
|
package/src/cli/share.ts
CHANGED
|
@@ -21,6 +21,10 @@ import {
|
|
|
21
21
|
normalizeEtag,
|
|
22
22
|
} from "../journal.js";
|
|
23
23
|
import { createIgnoreFilter, isWithinSizeLimit } from "../ignore.js";
|
|
24
|
+
import {
|
|
25
|
+
wrapFilterWithPersonalVaultDefaults,
|
|
26
|
+
type PersonalVaultExclusion,
|
|
27
|
+
} from "../personal-vault-exclusions.js";
|
|
24
28
|
import { resolveConflict } from "./conflict.js";
|
|
25
29
|
import type { ConflictStrategy } from "./conflict.js";
|
|
26
30
|
import type { SyncProgressEvent } from "./sync.js";
|
|
@@ -386,6 +390,15 @@ export interface ShareResult {
|
|
|
386
390
|
* `currency-gated`.
|
|
387
391
|
*/
|
|
388
392
|
filesRefusedStale: number;
|
|
393
|
+
/**
|
|
394
|
+
* Number of paths blocked by `PERSONAL_VAULT_DEFAULT_EXCLUSIONS` during this
|
|
395
|
+
* run (push leg, personalMode=true). Includes both files that would have
|
|
396
|
+
* uploaded and journal entries that would have been included in the delete
|
|
397
|
+
* plan; deduplicated across walks. Always 0 outside personalMode. Mirrors
|
|
398
|
+
* the `count` field of the `personal-vault-out-of-policy` event (which is
|
|
399
|
+
* emitted exactly once if this is > 0).
|
|
400
|
+
*/
|
|
401
|
+
filesExcludedByPolicy: number;
|
|
389
402
|
/**
|
|
390
403
|
* Paths (company-relative) that were detected as push conflicts. Mirrors
|
|
391
404
|
* `SyncResult.conflictPaths` so push and pull surface conflicts the same
|
|
@@ -463,7 +476,34 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
463
476
|
const syncRoot = options.personalMode === true
|
|
464
477
|
? hqRoot
|
|
465
478
|
: path.join(hqRoot, "companies", ctx.slug);
|
|
466
|
-
|
|
479
|
+
|
|
480
|
+
// Personal-vault default exclusions (introduced in 5.25): wrap the base
|
|
481
|
+
// ignore filter so paths matching `PERSONAL_VAULT_DEFAULT_EXCLUSIONS` are
|
|
482
|
+
// rejected before they upload OR enter the delete plan. Refuses & warns —
|
|
483
|
+
// an already-leaked remote object stays put as an orphan; a separate one-
|
|
484
|
+
// shot purge handles legacy litter.
|
|
485
|
+
//
|
|
486
|
+
// Out-of-policy hits are deduplicated in `excludedSet` so the same path
|
|
487
|
+
// hitting the filter from both the upload walk and the delete-plan walk
|
|
488
|
+
// counts once. `excludedById` powers the per-rule breakdown on the
|
|
489
|
+
// `personal-vault-out-of-policy` event so UI can render which class
|
|
490
|
+
// (secret / machine-local / scratch / …) did the work.
|
|
491
|
+
//
|
|
492
|
+
// Company-mode syncs skip this wrap entirely — company vaults have their
|
|
493
|
+
// own first-push protection (settings/, data/, workers/, .git/) defined
|
|
494
|
+
// in hq-sync's Rust util/ignore.rs, and a company may legitimately ship
|
|
495
|
+
// `output/` or `.env*` paths inside its `companies/{slug}/data/` folder.
|
|
496
|
+
const ignoreFilter = createIgnoreFilter(hqRoot);
|
|
497
|
+
const excludedSet = new Set<string>();
|
|
498
|
+
const excludedById: Record<string, number> = {};
|
|
499
|
+
const onExcluded = (rel: string, match: PersonalVaultExclusion) => {
|
|
500
|
+
if (excludedSet.has(rel)) return;
|
|
501
|
+
excludedSet.add(rel);
|
|
502
|
+
excludedById[match.id] = (excludedById[match.id] ?? 0) + 1;
|
|
503
|
+
};
|
|
504
|
+
const shouldSync = options.personalMode === true
|
|
505
|
+
? wrapFilterWithPersonalVaultDefaults(ignoreFilter, syncRoot, onExcluded)
|
|
506
|
+
: ignoreFilter;
|
|
467
507
|
const journalSlug = options.journalSlug ?? ctx.slug;
|
|
468
508
|
const journal = readJournal(journalSlug);
|
|
469
509
|
|
|
@@ -599,6 +639,12 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
599
639
|
// ShareResult shape stable for consumers that destructure.
|
|
600
640
|
filesTombstoned,
|
|
601
641
|
filesRefusedStale,
|
|
642
|
+
// Exclusions are computed during the upload walk which has
|
|
643
|
+
// already completed by the time we hit a per-file conflict-
|
|
644
|
+
// abort, so the count is meaningful here. No event emit on
|
|
645
|
+
// abort (matches the existing convention: abort short-circuits
|
|
646
|
+
// before the end-of-run telemetry emits).
|
|
647
|
+
filesExcludedByPolicy: excludedSet.size,
|
|
602
648
|
conflictPaths,
|
|
603
649
|
aborted: true,
|
|
604
650
|
};
|
|
@@ -727,6 +773,24 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
727
773
|
journal.lastSync = new Date().toISOString();
|
|
728
774
|
writeJournal(journalSlug, journal);
|
|
729
775
|
|
|
776
|
+
// Personal-vault out-of-policy summary. Emit at most once, only when at
|
|
777
|
+
// least one path was excluded. Sample is capped at 10 to keep the event
|
|
778
|
+
// small (Set iteration order = insertion order, so samples are the first
|
|
779
|
+
// ten paths encountered during the walk — deterministic, not random).
|
|
780
|
+
if (excludedSet.size > 0) {
|
|
781
|
+
const samplePaths: string[] = [];
|
|
782
|
+
for (const p of excludedSet) {
|
|
783
|
+
samplePaths.push(p);
|
|
784
|
+
if (samplePaths.length >= 10) break;
|
|
785
|
+
}
|
|
786
|
+
emit({
|
|
787
|
+
type: "personal-vault-out-of-policy",
|
|
788
|
+
count: excludedSet.size,
|
|
789
|
+
samplePaths,
|
|
790
|
+
byId: { ...excludedById },
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
|
|
730
794
|
return {
|
|
731
795
|
filesUploaded,
|
|
732
796
|
bytesUploaded,
|
|
@@ -734,6 +798,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
734
798
|
filesDeleted,
|
|
735
799
|
filesTombstoned,
|
|
736
800
|
filesRefusedStale,
|
|
801
|
+
filesExcludedByPolicy: excludedSet.size,
|
|
737
802
|
conflictPaths,
|
|
738
803
|
aborted: false,
|
|
739
804
|
};
|