@indigoai-us/hq-cloud 6.13.4 → 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.
Files changed (33) hide show
  1. package/dist/bin/sync-runner-company.d.ts +1 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  3. package/dist/bin/sync-runner-company.js +41 -2
  4. package/dist/bin/sync-runner-company.js.map +1 -1
  5. package/dist/bin/sync-runner-watch-loop.d.ts +9 -1
  6. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  7. package/dist/bin/sync-runner-watch-loop.js +85 -51
  8. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  9. package/dist/bin/sync-runner-watch-routes.d.ts +3 -0
  10. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-watch-routes.js +52 -1
  12. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  13. package/dist/bin/sync-runner.d.ts.map +1 -1
  14. package/dist/bin/sync-runner.js +19 -4
  15. package/dist/bin/sync-runner.js.map +1 -1
  16. package/dist/bin/sync-runner.test.js +666 -45
  17. package/dist/bin/sync-runner.test.js.map +1 -1
  18. package/dist/lib/net-errors.d.ts +36 -0
  19. package/dist/lib/net-errors.d.ts.map +1 -0
  20. package/dist/lib/net-errors.js +81 -0
  21. package/dist/lib/net-errors.js.map +1 -0
  22. package/dist/lib/net-errors.test.d.ts +2 -0
  23. package/dist/lib/net-errors.test.d.ts.map +1 -0
  24. package/dist/lib/net-errors.test.js +47 -0
  25. package/dist/lib/net-errors.test.js.map +1 -0
  26. package/package.json +1 -1
  27. package/src/bin/sync-runner-company.ts +61 -2
  28. package/src/bin/sync-runner-watch-loop.ts +116 -55
  29. package/src/bin/sync-runner-watch-routes.ts +57 -1
  30. package/src/bin/sync-runner.test.ts +774 -50
  31. package/src/bin/sync-runner.ts +23 -4
  32. package/src/lib/net-errors.test.ts +53 -0
  33. package/src/lib/net-errors.ts +83 -0
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Transient network-failure classification for the sync runner.
3
+ *
4
+ * The auto-sync watcher runs an unattended poll loop. At the top of every pass
5
+ * it calls `GET /membership/me` to resolve which companies to sync. When the
6
+ * machine is briefly offline (wifi drop, waking from sleep, a DNS blip, the
7
+ * vault API momentarily unreachable) that call fails at the transport layer —
8
+ * Node's `fetch` throws `TypeError: fetch failed` with the real cause (an
9
+ * `ECONNREFUSED` / `ENOTFOUND` / `ETIMEDOUT` / … Error) on `.cause`.
10
+ *
11
+ * This is NOT a crash: the next poll (~30s later) succeeds once the network is
12
+ * back. But the runner used to `return 1` for it, the watch loop propagated
13
+ * that non-zero exit, the process exited, and the menubar supervisor reported
14
+ * "auto-sync watcher exited unexpectedly (code=Some(1))" for every blip — the
15
+ * HQ-SYNC-1W cluster. We classify these so the watch loop can stay alive and
16
+ * retry instead of surfacing a false crash, while a one-shot `hq sync` still
17
+ * exits non-zero so a human running it by hand sees the failure.
18
+ */
19
+ /**
20
+ * Distinct runner exit code for a transient network failure. The watch loop
21
+ * treats it as "stay alive, retry next tick" (never a crash); a one-shot run
22
+ * still exits non-zero so the failure is visible to a human/script. Value 75 =
23
+ * `EX_TEMPFAIL` from sysexits.h ("temporary failure; retry"). Kept off the
24
+ * codes already in use by the runner (0 ok, 1 error, 2 partial, 17 op-locked).
25
+ */
26
+ export declare const TRANSIENT_NETWORK_EXIT = 75;
27
+ /**
28
+ * True when `err` is (or wraps) a transient network-transport failure. Walks the
29
+ * `.cause` chain and `AggregateError.errors` (undici's happy-eyeballs raises the
30
+ * latter), matching either undici's canonical `TypeError: fetch failed` message
31
+ * or a known transient `.code`. Conservative by design: an arbitrary
32
+ * `new Error("network down")` with no code and a non-canonical message is NOT
33
+ * treated as transient, so only real transport-failure shapes qualify.
34
+ */
35
+ export declare function isTransientNetworkError(err: unknown, depth?: number): boolean;
36
+ //# sourceMappingURL=net-errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"net-errors.d.ts","sourceRoot":"","sources":["../../src/lib/net-errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,KAAK,CAAC;AA0BzC;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,OAAO,EAAE,KAAK,SAAI,GAAG,OAAO,CAsBxE"}
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Transient network-failure classification for the sync runner.
3
+ *
4
+ * The auto-sync watcher runs an unattended poll loop. At the top of every pass
5
+ * it calls `GET /membership/me` to resolve which companies to sync. When the
6
+ * machine is briefly offline (wifi drop, waking from sleep, a DNS blip, the
7
+ * vault API momentarily unreachable) that call fails at the transport layer —
8
+ * Node's `fetch` throws `TypeError: fetch failed` with the real cause (an
9
+ * `ECONNREFUSED` / `ENOTFOUND` / `ETIMEDOUT` / … Error) on `.cause`.
10
+ *
11
+ * This is NOT a crash: the next poll (~30s later) succeeds once the network is
12
+ * back. But the runner used to `return 1` for it, the watch loop propagated
13
+ * that non-zero exit, the process exited, and the menubar supervisor reported
14
+ * "auto-sync watcher exited unexpectedly (code=Some(1))" for every blip — the
15
+ * HQ-SYNC-1W cluster. We classify these so the watch loop can stay alive and
16
+ * retry instead of surfacing a false crash, while a one-shot `hq sync` still
17
+ * exits non-zero so a human running it by hand sees the failure.
18
+ */
19
+ /**
20
+ * Distinct runner exit code for a transient network failure. The watch loop
21
+ * treats it as "stay alive, retry next tick" (never a crash); a one-shot run
22
+ * still exits non-zero so the failure is visible to a human/script. Value 75 =
23
+ * `EX_TEMPFAIL` from sysexits.h ("temporary failure; retry"). Kept off the
24
+ * codes already in use by the runner (0 ok, 1 error, 2 partial, 17 op-locked).
25
+ */
26
+ export const TRANSIENT_NETWORK_EXIT = 75;
27
+ /**
28
+ * Node/undici error `code`s that mean "the request never completed at the
29
+ * transport layer" — the machine could not reach the host. All are transient:
30
+ * a later attempt from the same machine can succeed unchanged.
31
+ */
32
+ const TRANSIENT_CODES = new Set([
33
+ "ECONNREFUSED",
34
+ "ECONNRESET",
35
+ "ENOTFOUND",
36
+ "EAI_AGAIN",
37
+ "ETIMEDOUT",
38
+ "EPIPE",
39
+ "EHOSTUNREACH",
40
+ "EHOSTDOWN",
41
+ "ENETUNREACH",
42
+ "ENETDOWN",
43
+ "ECONNABORTED",
44
+ // undici's own timeout/socket failures
45
+ "UND_ERR_CONNECT_TIMEOUT",
46
+ "UND_ERR_HEADERS_TIMEOUT",
47
+ "UND_ERR_BODY_TIMEOUT",
48
+ "UND_ERR_SOCKET",
49
+ ]);
50
+ /**
51
+ * True when `err` is (or wraps) a transient network-transport failure. Walks the
52
+ * `.cause` chain and `AggregateError.errors` (undici's happy-eyeballs raises the
53
+ * latter), matching either undici's canonical `TypeError: fetch failed` message
54
+ * or a known transient `.code`. Conservative by design: an arbitrary
55
+ * `new Error("network down")` with no code and a non-canonical message is NOT
56
+ * treated as transient, so only real transport-failure shapes qualify.
57
+ */
58
+ export function isTransientNetworkError(err, depth = 0) {
59
+ if (depth > 8 || !(err instanceof Error))
60
+ return false;
61
+ // undici's transport failure surfaces as exactly this message.
62
+ if (err.message === "fetch failed")
63
+ return true;
64
+ const code = err.code;
65
+ if (typeof code === "string" && TRANSIENT_CODES.has(code))
66
+ return true;
67
+ // AggregateError (e.g. happy-eyeballs) stashes the per-attempt errors here.
68
+ const aggregated = err.errors;
69
+ if (Array.isArray(aggregated)) {
70
+ for (const inner of aggregated) {
71
+ if (isTransientNetworkError(inner, depth + 1))
72
+ return true;
73
+ }
74
+ }
75
+ const cause = err.cause;
76
+ if (cause !== undefined && cause !== err) {
77
+ return isTransientNetworkError(cause, depth + 1);
78
+ }
79
+ return false;
80
+ }
81
+ //# sourceMappingURL=net-errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"net-errors.js","sourceRoot":"","sources":["../../src/lib/net-errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,EAAE,CAAC;AAEzC;;;;GAIG;AACH,MAAM,eAAe,GAAG,IAAI,GAAG,CAAS;IACtC,cAAc;IACd,YAAY;IACZ,WAAW;IACX,WAAW;IACX,WAAW;IACX,OAAO;IACP,cAAc;IACd,WAAW;IACX,aAAa;IACb,UAAU;IACV,cAAc;IACd,uCAAuC;IACvC,yBAAyB;IACzB,yBAAyB;IACzB,sBAAsB;IACtB,gBAAgB;CACjB,CAAC,CAAC;AAEH;;;;;;;GAOG;AACH,MAAM,UAAU,uBAAuB,CAAC,GAAY,EAAE,KAAK,GAAG,CAAC;IAC7D,IAAI,KAAK,GAAG,CAAC,IAAI,CAAC,CAAC,GAAG,YAAY,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IAEvD,+DAA+D;IAC/D,IAAI,GAAG,CAAC,OAAO,KAAK,cAAc;QAAE,OAAO,IAAI,CAAC;IAEhD,MAAM,IAAI,GAAI,GAA0B,CAAC,IAAI,CAAC;IAC9C,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,eAAe,CAAC,GAAG,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAEvE,4EAA4E;IAC5E,MAAM,UAAU,GAAI,GAA4B,CAAC,MAAM,CAAC;IACxD,IAAI,KAAK,CAAC,OAAO,CAAC,UAAU,CAAC,EAAE,CAAC;QAC9B,KAAK,MAAM,KAAK,IAAI,UAAU,EAAE,CAAC;YAC/B,IAAI,uBAAuB,CAAC,KAAK,EAAE,KAAK,GAAG,CAAC,CAAC;gBAAE,OAAO,IAAI,CAAC;QAC7D,CAAC;IACH,CAAC;IAED,MAAM,KAAK,GAAI,GAA2B,CAAC,KAAK,CAAC;IACjD,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,GAAG,EAAE,CAAC;QACzC,OAAO,uBAAuB,CAAC,KAAK,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC;IACnD,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=net-errors.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"net-errors.test.d.ts","sourceRoot":"","sources":["../../src/lib/net-errors.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { isTransientNetworkError, TRANSIENT_NETWORK_EXIT } from "./net-errors.js";
3
+ describe("isTransientNetworkError", () => {
4
+ it("treats undici's canonical `fetch failed` TypeError as transient", () => {
5
+ // This is the exact shape the runner's discovery step sees in production
6
+ // (HQ-SYNC-1W): `{"type":"error","message":"fetch failed","path":"(discovery)"}`.
7
+ expect(isTransientNetworkError(new TypeError("fetch failed"))).toBe(true);
8
+ });
9
+ it("treats known transport `.code`s as transient", () => {
10
+ for (const code of ["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ETIMEDOUT"]) {
11
+ const err = Object.assign(new Error("boom"), { code });
12
+ expect(isTransientNetworkError(err)).toBe(true);
13
+ }
14
+ });
15
+ it("walks the .cause chain (fetch failed wrapping a DNS error)", () => {
16
+ const cause = Object.assign(new Error("getaddrinfo ENOTFOUND api"), {
17
+ code: "ENOTFOUND",
18
+ });
19
+ const err = Object.assign(new TypeError("fetch failed"), { cause });
20
+ expect(isTransientNetworkError(err)).toBe(true);
21
+ });
22
+ it("walks AggregateError.errors (happy-eyeballs)", () => {
23
+ const agg = Object.assign(new AggregateError([
24
+ Object.assign(new Error("v4"), { code: "ECONNREFUSED" }),
25
+ Object.assign(new Error("v6"), { code: "EHOSTUNREACH" }),
26
+ ], "all attempts failed"), {});
27
+ expect(isTransientNetworkError(agg)).toBe(true);
28
+ });
29
+ it("is conservative: an arbitrary error is NOT transient", () => {
30
+ // A bare message like "network down" (no code, not the canonical undici
31
+ // message) must not be misclassified — only real transport shapes qualify.
32
+ expect(isTransientNetworkError(new Error("network down"))).toBe(false);
33
+ expect(isTransientNetworkError(new Error("boom"))).toBe(false);
34
+ expect(isTransientNetworkError("fetch failed")).toBe(false);
35
+ expect(isTransientNetworkError(undefined)).toBe(false);
36
+ });
37
+ it("does not infinite-loop on a self-referential cause", () => {
38
+ const err = new Error("weird");
39
+ err.cause = err;
40
+ expect(isTransientNetworkError(err)).toBe(false);
41
+ });
42
+ it("exposes a distinct exit code off the runner's other codes", () => {
43
+ expect(TRANSIENT_NETWORK_EXIT).toBe(75);
44
+ expect([0, 1, 2, 17]).not.toContain(TRANSIENT_NETWORK_EXIT);
45
+ });
46
+ });
47
+ //# sourceMappingURL=net-errors.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"net-errors.test.js","sourceRoot":"","sources":["../../src/lib/net-errors.test.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cloud",
3
- "version": "6.13.4",
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,
@@ -8,10 +8,10 @@ import {
8
8
  OPERATION_LOCKED_EXIT,
9
9
  } from "../operation-lock.js";
10
10
  import { describeError } from "../lib/describe-error.js";
11
+ import { TRANSIENT_NETWORK_EXIT } from "../lib/net-errors.js";
11
12
  import { getOrCreateMachineId } from "../lib/machine-id.js";
12
13
  import {
13
14
  TreeWatcher,
14
- WatchPushDriver,
15
15
  systemClock,
16
16
  type TreeChangeBatch,
17
17
  } from "../watcher.js";
@@ -26,18 +26,28 @@ import {
26
26
  type EventSyncHandles,
27
27
  } from "../sync/event-sync.js";
28
28
  import {
29
- buildFullFanoutPushArgv,
29
+ buildScopedDrainPullArgv,
30
+ buildScopedPushArgv,
30
31
  buildTargetedPullArgv,
31
- buildTargetedPushArgv,
32
32
  routeChangeToTarget,
33
- routesForBatch,
33
+ routeKey,
34
34
  type WatchRoute,
35
35
  } from "./sync-runner-watch-routes.js";
36
- 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;
37
39
 
38
40
  export interface ParsedLoopArgs {
39
41
  hqRoot: string;
40
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;
41
51
  }
42
52
 
43
53
  export interface WatchLoopRuntime {
@@ -91,9 +101,19 @@ export async function runWatchLoop(
91
101
  if (a === "--watch") return false;
92
102
  if (a === "--poll-remote-ms") return false;
93
103
  if (a === "--event-push") return false;
104
+ if (a === "--scope-path") return false;
94
105
  if (i > 0 && argv[i - 1] === "--poll-remote-ms") return false;
106
+ if (i > 0 && argv[i - 1] === "--scope-path") return false;
95
107
  return true;
96
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";
97
117
 
98
118
  if (parsed.lockTimeoutSec === 0) {
99
119
  try {
@@ -197,7 +217,6 @@ export async function runWatchLoop(
197
217
  };
198
218
 
199
219
  let watcher: WatcherSurface | null = null;
200
- let driver: WatchPushDriver | null = null;
201
220
  let detachSignal: (() => void) | null = null;
202
221
  const pendingWatcherPaths = new Map<string, string>();
203
222
  let pendingWatcherOriginalBatch: TreeChangeBatch | null = null;
@@ -236,8 +255,8 @@ export async function runWatchLoop(
236
255
  pendingWatcherBareChange = true;
237
256
  };
238
257
  const takePendingWatcherChange = (): {
239
- rel: string | null;
240
258
  batch: TreeChangeBatch | null;
259
+ bare: boolean;
241
260
  } => {
242
261
  const batch =
243
262
  pendingWatcherOriginalBatch !== null && !pendingWatcherOverflowed
@@ -254,15 +273,81 @@ export async function runWatchLoop(
254
273
  : {}),
255
274
  }
256
275
  : null;
257
- const rel = [...pendingWatcherPaths.values()][0] ?? null;
258
- const fallbackRel = rel ?? (pendingWatcherBareChange ? null : null);
276
+ const bare = pendingWatcherBareChange;
259
277
  pendingWatcherPaths.clear();
260
278
  pendingWatcherOriginalBatch = null;
261
279
  pendingWatcherBareChange = false;
262
280
  pendingWatcherOverflowed = false;
263
281
  pendingWatcherDroppedPaths = 0;
264
282
  pendingWatcherDroppedBytes = 0;
265
- 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));
266
351
  };
267
352
 
268
353
  let receiver: PushReceiver | null = null;
@@ -282,47 +367,9 @@ export async function runWatchLoop(
282
367
  }));
283
368
  watcher = createWatcher({ hqRoot, debounceMs, clock });
284
369
 
285
- driver = new WatchPushDriver({
286
- debounceMs: 0,
287
- clock,
288
- push: async () => {
289
- if (stopped) return;
290
- const { rel, batch: batchForPublish } = takePendingWatcherChange();
291
- const batchRoutes = batchForPublish
292
- ? routesForBatch(batchForPublish)
293
- : new Map<string, WatchRoute>();
294
- let targetedArgv: string[];
295
- if (batchForPublish?.overflowed || batchRoutes.size > 1) {
296
- targetedArgv = buildFullFanoutPushArgv(passArgv);
297
- } else {
298
- const route =
299
- batchRoutes.size === 1
300
- ? [...batchRoutes.values()][0]
301
- : rel
302
- ? routeChangeToTarget(rel)
303
- : { kind: "personal" as const };
304
- if (!route) return;
305
- targetedArgv = buildTargetedPushArgv(route, passArgv);
306
- }
307
- const result = await runGuarded(targetedArgv);
308
- if (
309
- result === 0 &&
310
- !stopped &&
311
- eventSync &&
312
- !batchForPublish?.overflowed
313
- ) {
314
- const batch: TreeChangeBatch | null =
315
- batchForPublish ??
316
- (rel ? { paths: new Map([[path.join(hqRoot, rel), rel]]) } : null);
317
- if (batch) eventSync.publishBatch(batch);
318
- }
319
- },
320
- });
321
-
322
370
  watcher.onChange((changedRelPath, batch) => {
323
371
  if (stopped) return;
324
372
  addPendingWatcherChange(changedRelPath, batch);
325
- driver?.notifyChange();
326
373
  });
327
374
  watcher.start();
328
375
 
@@ -402,11 +449,6 @@ export async function runWatchLoop(
402
449
  /* ignore */
403
450
  }
404
451
  eventSync = null;
405
- try {
406
- driver?.dispose();
407
- } catch {
408
- /* ignore */
409
- }
410
452
  try {
411
453
  watcher?.dispose();
412
454
  } catch {
@@ -427,10 +469,29 @@ export async function runWatchLoop(
427
469
  });
428
470
  detachSignal = onShutdownSignal(shutdown);
429
471
 
472
+ let pollTick = 0;
430
473
  try {
431
474
  while (!stopped) {
432
- const result = await runGuarded(passArgv);
433
- if (result !== 0) {
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);
485
+ if (result === TRANSIENT_NETWORK_EXIT) {
486
+ // The machine was briefly offline (the discovery fetch failed after
487
+ // retries). That is NOT a crash — stay alive and retry on the next
488
+ // poll. Returning it here would exit the watcher and the menubar would
489
+ // report "watcher exited unexpectedly" for every network blip
490
+ // (HQ-SYNC-1W). Log to stderr so it still shows up as a breadcrumb.
491
+ process.stderr.write(
492
+ "hq-sync-runner: watch pass skipped — transient network failure; retrying next poll\n",
493
+ );
494
+ } else if (result !== 0) {
434
495
  return result;
435
496
  }
436
497
  await Promise.race([sleep(pollMs), stoppedSignal]);
@@ -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.