@indigoai-us/hq-cloud 6.13.5 → 6.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cloud",
3
- "version": "6.13.5",
3
+ "version": "6.14.0",
4
4
  "description": "HQ by Indigo cloud sync engine — bidirectional S3 sync for mobile access",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -6,6 +6,13 @@ import type { ConflictStrategy } from "../cli/conflict.js";
6
6
  import type { UploadAuthor } from "../s3.js";
7
7
  import type { VaultServiceConfig } from "../index.js";
8
8
  import { resolvePullScope, type PullScope } from "../sync/pull-scope.js";
9
+ import {
10
+ coalescePrefixes,
11
+ pathToScopePrefix,
12
+ toScopePrefixEntries,
13
+ type ScopePrefixEntry,
14
+ type ScopePrefixInput,
15
+ } from "../prefix-coalesce.js";
9
16
  import { describeError } from "../lib/describe-error.js";
10
17
  import type {
11
18
  DeletePropagationPolicy,
@@ -42,6 +49,7 @@ export async function executeCompanyFanout(options: {
42
49
  shareFn: (options: ShareOptions) => Promise<ShareResult>;
43
50
  resolveDeletePolicy: () => DeletePropagationPolicy;
44
51
  emit: (event: RunnerEvent) => void;
52
+ scopePaths?: string[];
45
53
  }): Promise<CompanyFanoutResult> {
46
54
  const doPush =
47
55
  options.direction === "push" || options.direction === "both";
@@ -196,6 +204,10 @@ export async function executeCompanyFanout(options: {
196
204
 
197
205
  if (doPush) {
198
206
  activePhase = "push";
207
+ const pushPrefixSet = effectivePushPrefixSet(
208
+ scope.prefixSet,
209
+ options.scopePaths ?? [],
210
+ );
199
211
  const pushPaths =
200
212
  target.personalMode === true
201
213
  ? computePersonalVaultPaths(options.hqRoot, { teamSyncedSlugs })
@@ -224,8 +236,8 @@ export async function executeCompanyFanout(options: {
224
236
  ...(decommissionPrefixes && decommissionPrefixes.length > 0
225
237
  ? { decommissionPrefixes }
226
238
  : {}),
227
- ...(scope.prefixSet !== undefined
228
- ? { prefixSet: scope.prefixSet }
239
+ ...(pushPrefixSet !== undefined
240
+ ? { prefixSet: pushPrefixSet }
229
241
  : {}),
230
242
  });
231
243
  }
@@ -364,6 +376,53 @@ export async function executeCompanyFanout(options: {
364
376
  return { stateByCompany, errors, allConflicts };
365
377
  }
366
378
 
379
+ function effectivePushPrefixSet(
380
+ aclPrefixSet: readonly ScopePrefixInput[] | undefined,
381
+ scopePaths: readonly string[],
382
+ ): string[] | undefined {
383
+ if (scopePaths.length === 0) {
384
+ return aclPrefixSet === undefined
385
+ ? undefined
386
+ : coalescePrefixes(aclPrefixSet);
387
+ }
388
+
389
+ const requested = scopePaths.flatMap((p) => [
390
+ pathToScopePrefix(p),
391
+ pathToScopePrefix(p.endsWith("/") ? p : p + "/"),
392
+ ]);
393
+ if (aclPrefixSet === undefined) {
394
+ return coalescePrefixes(requested);
395
+ }
396
+
397
+ const aclEntries = toScopePrefixEntries(aclPrefixSet);
398
+ const requestedEntries = toScopePrefixEntries(requested);
399
+ const intersection: ScopePrefixEntry[] = [];
400
+ for (const requestedEntry of requestedEntries) {
401
+ for (const aclEntry of aclEntries) {
402
+ if (entryCoversEntry(aclEntry, requestedEntry)) {
403
+ intersection.push(requestedEntry);
404
+ } else if (entryCoversEntry(requestedEntry, aclEntry)) {
405
+ intersection.push(aclEntry);
406
+ }
407
+ }
408
+ }
409
+ return coalescePrefixes(intersection);
410
+ }
411
+
412
+ function entryCoversEntry(
413
+ entry: ScopePrefixEntry,
414
+ candidate: ScopePrefixEntry,
415
+ ): boolean {
416
+ if (entry.prefix === "") return true;
417
+ if (entry.match === "prefix") {
418
+ return (
419
+ candidate.prefix === entry.prefix ||
420
+ candidate.prefix.startsWith(entry.prefix)
421
+ );
422
+ }
423
+ return candidate.match === "exact" && candidate.prefix === entry.prefix;
424
+ }
425
+
367
426
  function normalizePullChangedPaths(
368
427
  target: RunnerTarget,
369
428
  changedPaths: string[] | undefined,
@@ -12,7 +12,6 @@ import { TRANSIENT_NETWORK_EXIT } from "../lib/net-errors.js";
12
12
  import { getOrCreateMachineId } from "../lib/machine-id.js";
13
13
  import {
14
14
  TreeWatcher,
15
- WatchPushDriver,
16
15
  systemClock,
17
16
  type TreeChangeBatch,
18
17
  } from "../watcher.js";
@@ -27,18 +26,28 @@ import {
27
26
  type EventSyncHandles,
28
27
  } from "../sync/event-sync.js";
29
28
  import {
30
- buildFullFanoutPushArgv,
29
+ buildScopedDrainPullArgv,
30
+ buildScopedPushArgv,
31
31
  buildTargetedPullArgv,
32
- buildTargetedPushArgv,
33
32
  routeChangeToTarget,
34
- routesForBatch,
33
+ routeKey,
35
34
  type WatchRoute,
36
35
  } from "./sync-runner-watch-routes.js";
37
- import type { RunnerLoopDeps, WatcherSurface } from "./sync-runner.js";
36
+ import type { Direction, RunnerLoopDeps, WatcherSurface } from "./sync-runner.js";
37
+
38
+ const FULL_RECONCILE_EVERY_TICKS = 10;
38
39
 
39
40
  export interface ParsedLoopArgs {
40
41
  hqRoot: string;
41
42
  lockTimeoutSec?: number;
43
+ /**
44
+ * The run's resolved sync direction, straight from `parseArgs` (the single
45
+ * source of truth — same default as the parser). The drain reads this to
46
+ * decide whether scoped pushes and/or the pull leg run; it must NOT re-parse
47
+ * `--direction` from argv, which mis-defaults an omitted flag and disagrees
48
+ * with the parser on duplicate flags.
49
+ */
50
+ direction?: Direction;
42
51
  }
43
52
 
44
53
  export interface WatchLoopRuntime {
@@ -92,9 +101,19 @@ export async function runWatchLoop(
92
101
  if (a === "--watch") return false;
93
102
  if (a === "--poll-remote-ms") return false;
94
103
  if (a === "--event-push") return false;
104
+ if (a === "--scope-path") return false;
95
105
  if (i > 0 && argv[i - 1] === "--poll-remote-ms") return false;
106
+ if (i > 0 && argv[i - 1] === "--scope-path") return false;
96
107
  return true;
97
108
  });
109
+ // Authoritative direction from parseArgs — never re-derive from argv (that
110
+ // mis-defaults an omitted --direction to "both" and picks the wrong
111
+ // occurrence on duplicates). Fall back to the parser's own default ("pull").
112
+ const originalDirection: Direction = parsed.direction ?? "pull";
113
+ const drainRunsPush =
114
+ originalDirection === "both" || originalDirection === "push";
115
+ const drainRunsPull =
116
+ originalDirection === "both" || originalDirection === "pull";
98
117
 
99
118
  if (parsed.lockTimeoutSec === 0) {
100
119
  try {
@@ -198,7 +217,6 @@ export async function runWatchLoop(
198
217
  };
199
218
 
200
219
  let watcher: WatcherSurface | null = null;
201
- let driver: WatchPushDriver | null = null;
202
220
  let detachSignal: (() => void) | null = null;
203
221
  const pendingWatcherPaths = new Map<string, string>();
204
222
  let pendingWatcherOriginalBatch: TreeChangeBatch | null = null;
@@ -237,8 +255,8 @@ export async function runWatchLoop(
237
255
  pendingWatcherBareChange = true;
238
256
  };
239
257
  const takePendingWatcherChange = (): {
240
- rel: string | null;
241
258
  batch: TreeChangeBatch | null;
259
+ bare: boolean;
242
260
  } => {
243
261
  const batch =
244
262
  pendingWatcherOriginalBatch !== null && !pendingWatcherOverflowed
@@ -255,15 +273,81 @@ export async function runWatchLoop(
255
273
  : {}),
256
274
  }
257
275
  : null;
258
- const rel = [...pendingWatcherPaths.values()][0] ?? null;
259
- const fallbackRel = rel ?? (pendingWatcherBareChange ? null : null);
276
+ const bare = pendingWatcherBareChange;
260
277
  pendingWatcherPaths.clear();
261
278
  pendingWatcherOriginalBatch = null;
262
279
  pendingWatcherBareChange = false;
263
280
  pendingWatcherOverflowed = false;
264
281
  pendingWatcherDroppedPaths = 0;
265
282
  pendingWatcherDroppedBytes = 0;
266
- return { rel: fallbackRel, batch };
283
+ return { batch, bare };
284
+ };
285
+ const restorePendingWatcherPaths = (paths: Map<string, string>): void => {
286
+ pendingWatcherOriginalBatch = null;
287
+ for (const [absolutePath, relativePath] of paths.entries()) {
288
+ pendingWatcherPaths.set(absolutePath, relativePath);
289
+ }
290
+ };
291
+
292
+ const groupBatchByRoute = (
293
+ batch: TreeChangeBatch,
294
+ ): Map<
295
+ string,
296
+ { route: WatchRoute; paths: Map<string, string>; relPaths: string[] }
297
+ > => {
298
+ const grouped = new Map<
299
+ string,
300
+ { route: WatchRoute; paths: Map<string, string>; relPaths: string[] }
301
+ >();
302
+ for (const [absolutePath, relativePath] of batch.paths.entries()) {
303
+ const route = routeChangeToTarget(relativePath);
304
+ if (!route) continue;
305
+ const key = routeKey(route);
306
+ const existing =
307
+ grouped.get(key) ??
308
+ { route, paths: new Map<string, string>(), relPaths: [] };
309
+ existing.paths.set(absolutePath, relativePath);
310
+ existing.relPaths = [...new Set([...existing.paths.values()])];
311
+ grouped.set(key, existing);
312
+ }
313
+ return grouped;
314
+ };
315
+
316
+ const publishBatchIfAvailable = (paths: Map<string, string>): void => {
317
+ if (!stopped && eventSync && paths.size > 0) {
318
+ eventSync.publishBatch({ paths: new Map(paths) });
319
+ }
320
+ };
321
+
322
+ const runScopedDrain = async (
323
+ batch: TreeChangeBatch | null,
324
+ ): Promise<number> => {
325
+ if (drainRunsPush && batch && batch.paths.size > 0) {
326
+ const grouped = groupBatchByRoute(batch);
327
+ for (const group of grouped.values()) {
328
+ const scopedArgv = buildScopedPushArgv(
329
+ group.route,
330
+ group.relPaths,
331
+ passArgv,
332
+ );
333
+ try {
334
+ const result = await runGuarded(scopedArgv);
335
+ if (result === 0) {
336
+ publishBatchIfAvailable(group.paths);
337
+ } else {
338
+ restorePendingWatcherPaths(group.paths);
339
+ }
340
+ } catch (err) {
341
+ restorePendingWatcherPaths(group.paths);
342
+ process.stderr.write(
343
+ `watch scoped push failed, will retry next tick: ${describeError(err)}\n`,
344
+ );
345
+ }
346
+ }
347
+ }
348
+
349
+ if (!drainRunsPull) return 0;
350
+ return runGuarded(buildScopedDrainPullArgv(passArgv));
267
351
  };
268
352
 
269
353
  let receiver: PushReceiver | null = null;
@@ -283,47 +367,9 @@ export async function runWatchLoop(
283
367
  }));
284
368
  watcher = createWatcher({ hqRoot, debounceMs, clock });
285
369
 
286
- driver = new WatchPushDriver({
287
- debounceMs: 0,
288
- clock,
289
- push: async () => {
290
- if (stopped) return;
291
- const { rel, batch: batchForPublish } = takePendingWatcherChange();
292
- const batchRoutes = batchForPublish
293
- ? routesForBatch(batchForPublish)
294
- : new Map<string, WatchRoute>();
295
- let targetedArgv: string[];
296
- if (batchForPublish?.overflowed || batchRoutes.size > 1) {
297
- targetedArgv = buildFullFanoutPushArgv(passArgv);
298
- } else {
299
- const route =
300
- batchRoutes.size === 1
301
- ? [...batchRoutes.values()][0]
302
- : rel
303
- ? routeChangeToTarget(rel)
304
- : { kind: "personal" as const };
305
- if (!route) return;
306
- targetedArgv = buildTargetedPushArgv(route, passArgv);
307
- }
308
- const result = await runGuarded(targetedArgv);
309
- if (
310
- result === 0 &&
311
- !stopped &&
312
- eventSync &&
313
- !batchForPublish?.overflowed
314
- ) {
315
- const batch: TreeChangeBatch | null =
316
- batchForPublish ??
317
- (rel ? { paths: new Map([[path.join(hqRoot, rel), rel]]) } : null);
318
- if (batch) eventSync.publishBatch(batch);
319
- }
320
- },
321
- });
322
-
323
370
  watcher.onChange((changedRelPath, batch) => {
324
371
  if (stopped) return;
325
372
  addPendingWatcherChange(changedRelPath, batch);
326
- driver?.notifyChange();
327
373
  });
328
374
  watcher.start();
329
375
 
@@ -403,11 +449,6 @@ export async function runWatchLoop(
403
449
  /* ignore */
404
450
  }
405
451
  eventSync = null;
406
- try {
407
- driver?.dispose();
408
- } catch {
409
- /* ignore */
410
- }
411
452
  try {
412
453
  watcher?.dispose();
413
454
  } catch {
@@ -428,9 +469,19 @@ export async function runWatchLoop(
428
469
  });
429
470
  detachSignal = onShutdownSignal(shutdown);
430
471
 
472
+ let pollTick = 0;
431
473
  try {
432
474
  while (!stopped) {
433
- const result = await runGuarded(passArgv);
475
+ pollTick++;
476
+ const pending = takePendingWatcherChange();
477
+ const shouldRunFull =
478
+ pollTick === 1 ||
479
+ pending.bare ||
480
+ pending.batch?.overflowed === true ||
481
+ pollTick % FULL_RECONCILE_EVERY_TICKS === 0;
482
+ const result = shouldRunFull
483
+ ? await runGuarded(passArgv)
484
+ : await runScopedDrain(pending.batch);
434
485
  if (result === TRANSIENT_NETWORK_EXIT) {
435
486
  // The machine was briefly offline (the discovery fetch failed after
436
487
  // retries). That is NOT a crash — stay alive and retry on the next
@@ -37,10 +37,48 @@ export function buildTargetedPushArgv(
37
37
  return ["--companies", "--direction", "push", ...carried];
38
38
  }
39
39
 
40
+ export function buildScopedPushArgv(
41
+ route: WatchRoute,
42
+ relPaths: readonly string[],
43
+ baseArgv: string[],
44
+ ): string[] {
45
+ const scopeArgs = scopedPathArgs(route, relPaths);
46
+ const carried = carriedFlags(baseArgv);
47
+ if (route.kind === "company") {
48
+ return [
49
+ "--company",
50
+ route.slug,
51
+ "--direction",
52
+ "push",
53
+ ...scopeArgs,
54
+ ...carried,
55
+ ];
56
+ }
57
+ return ["--personal", "--direction", "push", ...scopeArgs, ...carried];
58
+ }
59
+
40
60
  export function buildFullFanoutPushArgv(baseArgv: string[]): string[] {
41
61
  return ["--companies", "--direction", "push", ...carriedFlags(baseArgv)];
42
62
  }
43
63
 
64
+ export function buildScopedDrainPullArgv(baseArgv: string[]): string[] {
65
+ const carried = carriedFlags(baseArgv);
66
+ if (baseArgv.includes("--personal")) {
67
+ return ["--personal", "--direction", "pull", ...carried];
68
+ }
69
+ const companyIdx = baseArgv.indexOf("--company");
70
+ if (companyIdx >= 0 && baseArgv[companyIdx + 1] !== undefined) {
71
+ return [
72
+ "--company",
73
+ baseArgv[companyIdx + 1],
74
+ "--direction",
75
+ "pull",
76
+ ...carried,
77
+ ];
78
+ }
79
+ return ["--companies", "--direction", "pull", ...carried];
80
+ }
81
+
44
82
  /**
45
83
  * Build the argv for a targeted pull pass from a routed change.
46
84
  */
@@ -55,7 +93,7 @@ export function buildTargetedPullArgv(
55
93
  return ["--companies", "--direction", "pull", ...carried];
56
94
  }
57
95
 
58
- function routeKey(route: WatchRoute): string {
96
+ export function routeKey(route: WatchRoute): string {
59
97
  return route.kind === "company" ? `company:${route.slug}` : "personal";
60
98
  }
61
99
 
@@ -69,6 +107,24 @@ export function routesForBatch(batch: TreeChangeBatch): Map<string, WatchRoute>
69
107
  return routes;
70
108
  }
71
109
 
110
+ function scopedPathArgs(route: WatchRoute, relPaths: readonly string[]): string[] {
111
+ const args: string[] = [];
112
+ for (const relPath of relPaths) {
113
+ const scopePath = scopePathForRoute(route, relPath);
114
+ if (scopePath === null) continue;
115
+ args.push("--scope-path", scopePath);
116
+ }
117
+ return args;
118
+ }
119
+
120
+ function scopePathForRoute(route: WatchRoute, relPath: string): string | null {
121
+ const norm = relPath.split(path.sep).join("/").replace(/^\.\//, "");
122
+ if (route.kind === "personal") return norm;
123
+ const prefix = `companies/${route.slug}/`;
124
+ if (norm.startsWith(prefix)) return norm.slice(prefix.length);
125
+ return null;
126
+ }
127
+
72
128
  /**
73
129
  * Extract the `--hq-root` / `--on-conflict` pair-flags from a base argv so a
74
130
  * re-targeted pass inherits the same root and conflict policy.