@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
@@ -110,6 +110,10 @@ import type { ReindexOptions, ReindexResult } from "../cli/reindex.js";
110
110
  import { pruneConflictIndex } from "../lib/conflict-index.js";
111
111
  import { materializeCodexAgents } from "../agent-codex-instructions.js";
112
112
  import { getOrCreateMachineId } from "../lib/machine-id.js";
113
+ import {
114
+ isTransientNetworkError,
115
+ TRANSIENT_NETWORK_EXIT,
116
+ } from "../lib/net-errors.js";
113
117
  import { describeError } from "../lib/describe-error.js";
114
118
  import type { Clock, TreeChangeBatch } from "../watcher.js";
115
119
  import type { PushReceiver, SyncEngineFn } from "../sync/push-receiver.js";
@@ -719,6 +723,8 @@ interface ParsedArgs {
719
723
  * mode (single-company runs never visit the personal target).
720
724
  */
721
725
  skipPersonal: boolean;
726
+ /** Repeatable company/personal-relative path scope for push-only passes. */
727
+ scopePaths: string[];
722
728
  /**
723
729
  * Bounded wait (seconds) for the per-root operation lock when another op is
724
730
  * already running. `0` → refuse immediately (pre-wait behavior); omitted →
@@ -738,6 +744,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
738
744
  let watch = false;
739
745
  let pollRemoteMs: number | undefined;
740
746
  let skipPersonal = false;
747
+ const scopePaths: string[] = [];
741
748
  let eventPush = false;
742
749
  let lockTimeoutSec: number | undefined;
743
750
 
@@ -813,6 +820,12 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
813
820
  // @getindigo.ai identities for the first release.
814
821
  eventPush = true;
815
822
  break;
823
+ case "--scope-path": {
824
+ const val = argv[++i];
825
+ if (!val) return { error: "--scope-path requires a value" };
826
+ scopePaths.push(val);
827
+ break;
828
+ }
816
829
  case "--lock-timeout": {
817
830
  const val = argv[++i];
818
831
  if (!val) return { error: "--lock-timeout requires a value (seconds)" };
@@ -863,6 +876,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
863
876
  watch,
864
877
  pollRemoteMs,
865
878
  skipPersonal,
879
+ scopePaths,
866
880
  eventPush,
867
881
  lockTimeoutSec,
868
882
  };
@@ -1160,15 +1174,19 @@ export async function runRunner(
1160
1174
  });
1161
1175
  return 0;
1162
1176
  }
1163
- // Any other failure is unrecoverable — surface as an error event and
1164
- // exit non-zero so the spawner knows the runner didn't get far enough
1165
- // to emit a useful protocol stream.
1177
+ // Surface the failure as an error event for observability either way.
1166
1178
  emit({
1167
1179
  type: "error",
1168
1180
  message: err instanceof Error ? err.message : String(err),
1169
1181
  path: "(discovery)",
1170
1182
  });
1171
- return 1;
1183
+ // A transient network failure (offline, DNS blip, vault API briefly
1184
+ // unreachable) is NOT a crash — it self-heals on the next poll. Return the
1185
+ // retryable exit code so the watch loop stays alive instead of exiting and
1186
+ // being reported as "watcher exited unexpectedly (code=Some(1))" for every
1187
+ // blip (HQ-SYNC-1W). A one-shot run still exits non-zero. Any other failure
1188
+ // is treated as a hard error (exit 1), as before.
1189
+ return isTransientNetworkError(err) ? TRANSIENT_NETWORK_EXIT : 1;
1172
1190
  }
1173
1191
 
1174
1192
  const targetPlan = await buildFanoutPlan({
@@ -1211,6 +1229,7 @@ export async function runRunner(
1211
1229
  shareFn,
1212
1230
  resolveDeletePolicy,
1213
1231
  emit,
1232
+ scopePaths: parsed.scopePaths,
1214
1233
  });
1215
1234
  const { errors, allConflicts } = fanout;
1216
1235
  const rollup = rollupAllComplete(plan, fanout.stateByCompany);
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { isTransientNetworkError, TRANSIENT_NETWORK_EXIT } from "./net-errors.js";
3
+
4
+ describe("isTransientNetworkError", () => {
5
+ it("treats undici's canonical `fetch failed` TypeError as transient", () => {
6
+ // This is the exact shape the runner's discovery step sees in production
7
+ // (HQ-SYNC-1W): `{"type":"error","message":"fetch failed","path":"(discovery)"}`.
8
+ expect(isTransientNetworkError(new TypeError("fetch failed"))).toBe(true);
9
+ });
10
+
11
+ it("treats known transport `.code`s as transient", () => {
12
+ for (const code of ["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ETIMEDOUT"]) {
13
+ const err = Object.assign(new Error("boom"), { code });
14
+ expect(isTransientNetworkError(err)).toBe(true);
15
+ }
16
+ });
17
+
18
+ it("walks the .cause chain (fetch failed wrapping a DNS error)", () => {
19
+ const cause = Object.assign(new Error("getaddrinfo ENOTFOUND api"), {
20
+ code: "ENOTFOUND",
21
+ });
22
+ const err = Object.assign(new TypeError("fetch failed"), { cause });
23
+ expect(isTransientNetworkError(err)).toBe(true);
24
+ });
25
+
26
+ it("walks AggregateError.errors (happy-eyeballs)", () => {
27
+ const agg = Object.assign(new AggregateError([
28
+ Object.assign(new Error("v4"), { code: "ECONNREFUSED" }),
29
+ Object.assign(new Error("v6"), { code: "EHOSTUNREACH" }),
30
+ ], "all attempts failed"), {});
31
+ expect(isTransientNetworkError(agg)).toBe(true);
32
+ });
33
+
34
+ it("is conservative: an arbitrary error is NOT transient", () => {
35
+ // A bare message like "network down" (no code, not the canonical undici
36
+ // message) must not be misclassified — only real transport shapes qualify.
37
+ expect(isTransientNetworkError(new Error("network down"))).toBe(false);
38
+ expect(isTransientNetworkError(new Error("boom"))).toBe(false);
39
+ expect(isTransientNetworkError("fetch failed")).toBe(false);
40
+ expect(isTransientNetworkError(undefined)).toBe(false);
41
+ });
42
+
43
+ it("does not infinite-loop on a self-referential cause", () => {
44
+ const err = new Error("weird") as Error & { cause?: unknown };
45
+ err.cause = err;
46
+ expect(isTransientNetworkError(err)).toBe(false);
47
+ });
48
+
49
+ it("exposes a distinct exit code off the runner's other codes", () => {
50
+ expect(TRANSIENT_NETWORK_EXIT).toBe(75);
51
+ expect([0, 1, 2, 17]).not.toContain(TRANSIENT_NETWORK_EXIT);
52
+ });
53
+ });
@@ -0,0 +1,83 @@
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
+ /**
21
+ * Distinct runner exit code for a transient network failure. The watch loop
22
+ * treats it as "stay alive, retry next tick" (never a crash); a one-shot run
23
+ * still exits non-zero so the failure is visible to a human/script. Value 75 =
24
+ * `EX_TEMPFAIL` from sysexits.h ("temporary failure; retry"). Kept off the
25
+ * codes already in use by the runner (0 ok, 1 error, 2 partial, 17 op-locked).
26
+ */
27
+ export const TRANSIENT_NETWORK_EXIT = 75;
28
+
29
+ /**
30
+ * Node/undici error `code`s that mean "the request never completed at the
31
+ * transport layer" — the machine could not reach the host. All are transient:
32
+ * a later attempt from the same machine can succeed unchanged.
33
+ */
34
+ const TRANSIENT_CODES = new Set<string>([
35
+ "ECONNREFUSED",
36
+ "ECONNRESET",
37
+ "ENOTFOUND",
38
+ "EAI_AGAIN",
39
+ "ETIMEDOUT",
40
+ "EPIPE",
41
+ "EHOSTUNREACH",
42
+ "EHOSTDOWN",
43
+ "ENETUNREACH",
44
+ "ENETDOWN",
45
+ "ECONNABORTED",
46
+ // undici's own timeout/socket failures
47
+ "UND_ERR_CONNECT_TIMEOUT",
48
+ "UND_ERR_HEADERS_TIMEOUT",
49
+ "UND_ERR_BODY_TIMEOUT",
50
+ "UND_ERR_SOCKET",
51
+ ]);
52
+
53
+ /**
54
+ * True when `err` is (or wraps) a transient network-transport failure. Walks the
55
+ * `.cause` chain and `AggregateError.errors` (undici's happy-eyeballs raises the
56
+ * latter), matching either undici's canonical `TypeError: fetch failed` message
57
+ * or a known transient `.code`. Conservative by design: an arbitrary
58
+ * `new Error("network down")` with no code and a non-canonical message is NOT
59
+ * treated as transient, so only real transport-failure shapes qualify.
60
+ */
61
+ export function isTransientNetworkError(err: unknown, depth = 0): boolean {
62
+ if (depth > 8 || !(err instanceof Error)) return false;
63
+
64
+ // undici's transport failure surfaces as exactly this message.
65
+ if (err.message === "fetch failed") return true;
66
+
67
+ const code = (err as { code?: unknown }).code;
68
+ if (typeof code === "string" && TRANSIENT_CODES.has(code)) return true;
69
+
70
+ // AggregateError (e.g. happy-eyeballs) stashes the per-attempt errors here.
71
+ const aggregated = (err as { errors?: unknown }).errors;
72
+ if (Array.isArray(aggregated)) {
73
+ for (const inner of aggregated) {
74
+ if (isTransientNetworkError(inner, depth + 1)) return true;
75
+ }
76
+ }
77
+
78
+ const cause = (err as { cause?: unknown }).cause;
79
+ if (cause !== undefined && cause !== err) {
80
+ return isTransientNetworkError(cause, depth + 1);
81
+ }
82
+ return false;
83
+ }