@indigoai-us/hq-cloud 6.14.41 → 6.14.43

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.
@@ -2,9 +2,10 @@
2
2
  * Transient network-failure classification for the sync runner.
3
3
  *
4
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 —
5
+ * it calls `GET /membership/me` to resolve which companies to sync, then it
6
+ * runs a per-company fanout. When the machine is briefly offline (wifi drop,
7
+ * waking from sleep, a DNS blip, the vault API or a vault S3 endpoint
8
+ * momentarily unreachable), either boundary can fail at the transport layer —
8
9
  * Node's `fetch` throws `TypeError: fetch failed` with the real cause (an
9
10
  * `ECONNREFUSED` / `ENOTFOUND` / `ETIMEDOUT` / … Error) on `.cause`.
10
11
  *
@@ -12,9 +13,11 @@
12
13
  * back. But the runner used to `return 1` for it, the watch loop propagated
13
14
  * that non-zero exit, the process exited, and the menubar supervisor reported
14
15
  * "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.
16
+ * HQ-SYNC-1W cluster. A company-leg failure was also previously counted as an
17
+ * exit-2 partial sync, producing the HQ-SYNC-X symptom even though it was just
18
+ * retryable DNS/transport loss. We classify both boundaries so the watch loop
19
+ * can stay alive and retry instead of surfacing a false crash, while a one-shot
20
+ * `hq sync` still exits non-zero so a human running it by hand sees the failure.
18
21
  */
19
22
  /**
20
23
  * Distinct runner exit code for a transient network failure. The watch loop
@@ -1 +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"}
1
+ {"version":3,"file":"net-errors.d.ts","sourceRoot":"","sources":["../../src/lib/net-errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,KAAK,CAAC;AA0BzC;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,OAAO,EAAE,KAAK,SAAI,GAAG,OAAO,CAsBxE"}
@@ -2,9 +2,10 @@
2
2
  * Transient network-failure classification for the sync runner.
3
3
  *
4
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 —
5
+ * it calls `GET /membership/me` to resolve which companies to sync, then it
6
+ * runs a per-company fanout. When the machine is briefly offline (wifi drop,
7
+ * waking from sleep, a DNS blip, the vault API or a vault S3 endpoint
8
+ * momentarily unreachable), either boundary can fail at the transport layer —
8
9
  * Node's `fetch` throws `TypeError: fetch failed` with the real cause (an
9
10
  * `ECONNREFUSED` / `ENOTFOUND` / `ETIMEDOUT` / … Error) on `.cause`.
10
11
  *
@@ -12,9 +13,11 @@
12
13
  * back. But the runner used to `return 1` for it, the watch loop propagated
13
14
  * that non-zero exit, the process exited, and the menubar supervisor reported
14
15
  * "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.
16
+ * HQ-SYNC-1W cluster. A company-leg failure was also previously counted as an
17
+ * exit-2 partial sync, producing the HQ-SYNC-X symptom even though it was just
18
+ * retryable DNS/transport loss. We classify both boundaries so the watch loop
19
+ * can stay alive and retry instead of surfacing a false crash, while a one-shot
20
+ * `hq sync` still exits non-zero so a human running it by hand sees the failure.
18
21
  */
19
22
  /**
20
23
  * Distinct runner exit code for a transient network failure. The watch loop
@@ -1 +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"}
1
+ {"version":3,"file":"net-errors.js","sourceRoot":"","sources":["../../src/lib/net-errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;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"}
@@ -6,7 +6,7 @@ describe("isTransientNetworkError", () => {
6
6
  // (HQ-SYNC-1W): `{"type":"error","message":"fetch failed","path":"(discovery)"}`.
7
7
  expect(isTransientNetworkError(new TypeError("fetch failed"))).toBe(true);
8
8
  });
9
- it("treats known transport `.code`s as transient", () => {
9
+ it("treats direct transport `.code`s as transient", () => {
10
10
  for (const code of ["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ETIMEDOUT"]) {
11
11
  const err = Object.assign(new Error("boom"), { code });
12
12
  expect(isTransientNetworkError(err)).toBe(true);
@@ -19,6 +19,14 @@ describe("isTransientNetworkError", () => {
19
19
  const err = Object.assign(new TypeError("fetch failed"), { cause });
20
20
  expect(isTransientNetworkError(err)).toBe(true);
21
21
  });
22
+ it("walks an AWS SDK UnknownError cause chain to the underlying DNS error", () => {
23
+ const cause = Object.assign(new Error("getaddrinfo ENOTFOUND hq-vault-prs-test.s3.us-east-1.amazonaws.com"), { code: "ENOTFOUND", syscall: "getaddrinfo" });
24
+ const err = Object.assign(new Error("UnknownError"), {
25
+ name: "UnknownError",
26
+ cause,
27
+ });
28
+ expect(isTransientNetworkError(err)).toBe(true);
29
+ });
22
30
  it("walks AggregateError.errors (happy-eyeballs)", () => {
23
31
  const agg = Object.assign(new AggregateError([
24
32
  Object.assign(new Error("v4"), { code: "ECONNREFUSED" }),
@@ -1 +1 @@
1
- {"version":3,"file":"net-errors.test.js","sourceRoot":"","sources":["../../src/lib/net-errors.test.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"net-errors.test.js","sourceRoot":"","sources":["../../src/lib/net-errors.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAC;AAElF,QAAQ,CAAC,yBAAyB,EAAE,GAAG,EAAE;IACvC,EAAE,CAAC,iEAAiE,EAAE,GAAG,EAAE;QACzE,yEAAyE;QACzE,kFAAkF;QAClF,MAAM,CAAC,uBAAuB,CAAC,IAAI,SAAS,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC5E,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,+CAA+C,EAAE,GAAG,EAAE;QACvD,KAAK,MAAM,IAAI,IAAI,CAAC,cAAc,EAAE,WAAW,EAAE,WAAW,EAAE,WAAW,CAAC,EAAE,CAAC;YAC3E,MAAM,GAAG,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;YACvD,MAAM,CAAC,uBAAuB,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAClD,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,4DAA4D,EAAE,GAAG,EAAE;QACpE,MAAM,KAAK,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,2BAA2B,CAAC,EAAE;YAClE,IAAI,EAAE,WAAW;SAClB,CAAC,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,SAAS,CAAC,cAAc,CAAC,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC;QACpE,MAAM,CAAC,uBAAuB,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAClD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,uEAAuE,EAAE,GAAG,EAAE;QAC/E,MAAM,KAAK,GAAG,MAAM,CAAC,MAAM,CACzB,IAAI,KAAK,CAAC,oEAAoE,CAAC,EAC/E,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,aAAa,EAAE,CAC9C,CAAC;QACF,MAAM,GAAG,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,cAAc,CAAC,EAAE;YACnD,IAAI,EAAE,cAAc;YACpB,KAAK;SACN,CAAC,CAAC;QACH,MAAM,CAAC,uBAAuB,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAClD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,8CAA8C,EAAE,GAAG,EAAE;QACtD,MAAM,GAAG,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,cAAc,CAAC;YAC3C,MAAM,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,EAAE,cAAc,EAAE,CAAC;YACxD,MAAM,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,EAAE,cAAc,EAAE,CAAC;SACzD,EAAE,qBAAqB,CAAC,EAAE,EAAE,CAAC,CAAC;QAC/B,MAAM,CAAC,uBAAuB,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAClD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,sDAAsD,EAAE,GAAG,EAAE;QAC9D,wEAAwE;QACxE,2EAA2E;QAC3E,MAAM,CAAC,uBAAuB,CAAC,IAAI,KAAK,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACvE,MAAM,CAAC,uBAAuB,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC/D,MAAM,CAAC,uBAAuB,CAAC,cAAc,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC5D,MAAM,CAAC,uBAAuB,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACzD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,oDAAoD,EAAE,GAAG,EAAE;QAC5D,MAAM,GAAG,GAAG,IAAI,KAAK,CAAC,OAAO,CAAgC,CAAC;QAC9D,GAAG,CAAC,KAAK,GAAG,GAAG,CAAC;QAChB,MAAM,CAAC,uBAAuB,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACnD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2DAA2D,EAAE,GAAG,EAAE;QACnE,MAAM,CAAC,sBAAsB,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACxC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,sBAAsB,CAAC,CAAC;IAC9D,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cloud",
3
- "version": "6.14.41",
3
+ "version": "6.14.43",
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",
@@ -22,6 +22,7 @@ import {
22
22
  type ScopePrefixInput,
23
23
  } from "../prefix-coalesce.js";
24
24
  import { describeError } from "../lib/describe-error.js";
25
+ import { isTransientNetworkError } from "../lib/net-errors.js";
25
26
  import { VaultAuthError } from "../vault-client.js";
26
27
  import type {
27
28
  DeletePropagationPolicy,
@@ -39,6 +40,7 @@ export interface CompanyFanoutResult {
39
40
  stateByCompany: Map<string, CompanyState>;
40
41
  errors: Array<{ company: string; message: string }>;
41
42
  companyFailures: Array<{ company: string; message: string }>;
43
+ transientCompanies: Array<{ company: string; message: string }>;
42
44
  meaningfulOutcomeCount: number;
43
45
  allConflicts: Array<{
44
46
  company: string;
@@ -78,6 +80,7 @@ export async function executeCompanyFanout(options: {
78
80
  options.direction === "pull" || options.direction === "both";
79
81
  const errors: Array<{ company: string; message: string }> = [];
80
82
  const companyFailures: Array<{ company: string; message: string }> = [];
83
+ const transientCompanies: Array<{ company: string; message: string }> = [];
81
84
  const allConflicts: Array<{
82
85
  company: string;
83
86
  path: string;
@@ -489,8 +492,18 @@ export async function executeCompanyFanout(options: {
489
492
  // session and emit one auth-error event for manual and watch consumers.
490
493
  if (err instanceof VaultAuthError) throw err;
491
494
  const message = describeError(err);
492
- errors.push({ company: companyLabel, message });
493
- companyFailures.push({ company: companyLabel, message });
495
+ const transientNetwork = isTransientNetworkError(err);
496
+ // A prior path-level error is deterministic and must continue to win in
497
+ // the all-complete status and telemetry even if the leg later encounters
498
+ // a retryable transport failure.
499
+ const transientOnly = transientNetwork && !companyHadTransferError;
500
+ if (transientNetwork) {
501
+ transientCompanies.push({ company: companyLabel, message });
502
+ if (transientOnly) state.status = "transient-network";
503
+ } else {
504
+ errors.push({ company: companyLabel, message });
505
+ companyFailures.push({ company: companyLabel, message });
506
+ }
494
507
  meaningfulOutcomeCount += 1;
495
508
  options.emit({
496
509
  type: "complete",
@@ -514,12 +527,25 @@ export async function executeCompanyFanout(options: {
514
527
  scopeOrphansBlocked: 0,
515
528
  changedPaths: [],
516
529
  });
517
- options.emit({
518
- type: "error",
519
- company: companyLabel,
520
- path: "(company)",
521
- message,
522
- });
530
+ if (transientNetwork) {
531
+ // This is deliberately not an error event: a retryable transport blip
532
+ // must remain visible without being miscounted as a deterministic
533
+ // company failure (and therefore an exit-2/Sentry error). The complete
534
+ // event above still reports aborted:true and the pass exits 75.
535
+ options.emit({
536
+ type: "transient-network",
537
+ company: companyLabel,
538
+ path: "(company)",
539
+ message,
540
+ });
541
+ } else {
542
+ options.emit({
543
+ type: "error",
544
+ company: companyLabel,
545
+ path: "(company)",
546
+ message,
547
+ });
548
+ }
523
549
  void emitCloudTelemetry(
524
550
  options.telemetryClient,
525
551
  {
@@ -531,7 +557,7 @@ export async function executeCompanyFanout(options: {
531
557
  : {}),
532
558
  properties: {
533
559
  direction: options.direction,
534
- status: "errored",
560
+ status: transientOnly ? "transient-network" : "errored",
535
561
  filesDownloaded: state.filesDownloaded,
536
562
  bytesDownloaded: state.bytesDownloaded,
537
563
  filesUploaded: state.filesUploaded,
@@ -554,6 +580,7 @@ export async function executeCompanyFanout(options: {
554
580
  stateByCompany,
555
581
  errors,
556
582
  companyFailures,
583
+ transientCompanies,
557
584
  meaningfulOutcomeCount,
558
585
  allConflicts,
559
586
  pushPathResults,
@@ -1,7 +1,7 @@
1
1
  import type { RunnerEvent } from "./sync-runner.js";
2
2
  import type { RunnerTarget } from "./sync-runner-planning.js";
3
3
 
4
- export type CompanyStatus = "complete" | "aborted" | "errored";
4
+ export type CompanyStatus = "complete" | "aborted" | "errored" | "transient-network";
5
5
 
6
6
  export interface CompanyState {
7
7
  company: string;
@@ -1875,17 +1875,12 @@ describe("per-company fanout", () => {
1875
1875
  });
1876
1876
 
1877
1877
  /**
1878
- * Regression test: AWS SDK v3 raises an opaque `UnknownError` (name and
1879
- * message both literally "UnknownError") when it cannot match a Node-side
1880
- * networking failure to a modeled exception. The actual root cause —
1881
- * ENOTFOUND from a stale DNS cache, ECONNRESET on a flapping link, etc. —
1882
- * is parked on `err.cause`. Pre-fix the per-company catch extracted only
1883
- * `err.message`, so an unactionable bare "UnknownError" reached operators
1884
- * and the underlying syscall/hostname were lost. The catch now runs the
1885
- * thrown value through `describeError`, which walks the cause chain and
1886
- * stitches a one-line diagnostic the operator can act on.
1878
+ * AWS SDK v3 can wrap a Node-side DNS error in an opaque `UnknownError`.
1879
+ * The cause-chain classifier must treat this exactly like the direct
1880
+ * ENOTFOUND shape from HQ-SYNC-X: visible and retryable, never an exit-2
1881
+ * company error.
1887
1882
  */
1888
- it("per-company error event surfaces cause-chain (ENOTFOUND from SDK UnknownError)", async () => {
1883
+ it("classifies an SDK UnknownError whose cause is ENOTFOUND as transient (HQ-SYNC-X)", async () => {
1889
1884
  const deps = makeDeps({
1890
1885
  createVaultClient: () =>
1891
1886
  makeVaultStub({
@@ -1914,22 +1909,173 @@ describe("per-company fanout", () => {
1914
1909
  });
1915
1910
 
1916
1911
  const code = await runRunner(["--companies"], deps);
1917
- expect(code).toBe(2);
1912
+ expect(code).toBe(TRANSIENT_NETWORK_EXIT);
1918
1913
 
1919
- const companyErr = deps.stderr
1914
+ expect(
1915
+ deps.stderr
1916
+ .events()
1917
+ .some((e) => e.type === "error" && e.company === "privy"),
1918
+ ).toBe(false);
1919
+ const transient = deps.stdout
1920
1920
  .events()
1921
- .find(
1921
+ .find((e) => (e as { type: string }).type === "transient-network") as
1922
+ | { company: string; path: string; message: string }
1923
+ | undefined;
1924
+ expect(transient).toMatchObject({
1925
+ company: "privy",
1926
+ path: "(company)",
1927
+ });
1928
+ expect(transient?.message).toContain("UnknownError");
1929
+ expect(transient?.message).toContain("ENOTFOUND");
1930
+ expect(transient?.message).toContain("getaddrinfo");
1931
+ expect(transient?.message).toContain("privy.s3.us-east-1.amazonaws.com");
1932
+ });
1933
+
1934
+ it("returns 75, preserves the aborted complete event, and reports a direct ENOTFOUND leg as transient", async () => {
1935
+ const transportError = Object.assign(
1936
+ new Error("getaddrinfo ENOTFOUND hq-vault-prs-test.s3.us-east-1.amazonaws.com"),
1937
+ { code: "ENOTFOUND" },
1938
+ );
1939
+ const deps = makeDeps({
1940
+ createVaultClient: () =>
1941
+ makeVaultStub({
1942
+ memberships: [{ companyUid: "cmp_acme" }],
1943
+ entityGet: (uid: string) =>
1944
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
1945
+ }),
1946
+ sync: vi.fn().mockRejectedValue(transportError),
1947
+ });
1948
+
1949
+ const code = await runRunner(["--companies"], deps);
1950
+
1951
+ expect(code).toBe(TRANSIENT_NETWORK_EXIT);
1952
+ expect(
1953
+ deps.stderr
1954
+ .events()
1955
+ .some((e) => e.type === "error" && e.path === "(company)"),
1956
+ ).toBe(false);
1957
+ expect(
1958
+ deps.stdout.events().find(
1959
+ (e) => (e as { type: string }).type === "transient-network",
1960
+ ),
1961
+ ).toMatchObject({
1962
+ company: "acme",
1963
+ path: "(company)",
1964
+ message: expect.stringContaining("code=ENOTFOUND"),
1965
+ });
1966
+ expect(
1967
+ deps.stdout.events().find(
1968
+ (e): e is Extract<RunnerEvent, { type: "complete" }> =>
1969
+ e.type === "complete" && e.company === "acme",
1970
+ ),
1971
+ ).toMatchObject({ aborted: true });
1972
+ const all = deps.stdout
1973
+ .events()
1974
+ .find((e) => e.type === "all-complete") as {
1975
+ errors: Array<{ company: string; message: string }>;
1976
+ transient?: Array<{ company: string; message: string }>;
1977
+ companies: Array<{ company: string; status: string }>;
1978
+ };
1979
+ expect(all.errors).toEqual([]);
1980
+ expect(all.transient).toEqual([
1981
+ {
1982
+ company: "acme",
1983
+ message: expect.stringContaining("code=ENOTFOUND"),
1984
+ },
1985
+ ]);
1986
+ expect(all.companies).toEqual([
1987
+ expect.objectContaining({ company: "acme", status: "transient-network" }),
1988
+ ]);
1989
+ });
1990
+
1991
+ it("keeps deterministic company failures as exit 2 errors", async () => {
1992
+ const deps = makeDeps({
1993
+ createVaultClient: () =>
1994
+ makeVaultStub({
1995
+ memberships: [{ companyUid: "cmp_acme" }],
1996
+ entityGet: (uid: string) =>
1997
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
1998
+ }),
1999
+ sync: vi.fn().mockRejectedValue(new Error("AccessDenied: forbidden")),
2000
+ });
2001
+
2002
+ expect(await runRunner(["--companies"], deps)).toBe(PARTIAL_SYNC_EXIT);
2003
+ expect(
2004
+ deps.stderr.events().find(
1922
2005
  (e): e is Extract<RunnerEvent, { type: "error" }> =>
1923
- e.type === "error" && e.company === "privy",
1924
- );
1925
- expect(companyErr).toBeDefined();
1926
- expect(companyErr?.path).toBe("(company)");
1927
- // The diagnostic must carry the original networking signal — without
1928
- // this the operator only sees "UnknownError" and the run is unactionable.
1929
- expect(companyErr?.message).toContain("UnknownError");
1930
- expect(companyErr?.message).toContain("ENOTFOUND");
1931
- expect(companyErr?.message).toContain("getaddrinfo");
1932
- expect(companyErr?.message).toContain("privy.s3.us-east-1.amazonaws.com");
2006
+ e.type === "error" && e.company === "acme" && e.path === "(company)",
2007
+ ),
2008
+ ).toBeDefined();
2009
+ expect(
2010
+ deps.stdout
2011
+ .events()
2012
+ .some((e) => (e as { type: string }).type === "transient-network"),
2013
+ ).toBe(false);
2014
+ });
2015
+
2016
+ it("lets a real company error win over a transient company leg", async () => {
2017
+ const dnsError = Object.assign(new Error("getaddrinfo ENOTFOUND vault"), {
2018
+ code: "ENOTFOUND",
2019
+ });
2020
+ const slugs: Record<string, string> = { cmp_acme: "acme", cmp_beta: "beta" };
2021
+ const deps = makeDeps({
2022
+ createVaultClient: () =>
2023
+ makeVaultStub({
2024
+ memberships: [{ companyUid: "cmp_acme" }, { companyUid: "cmp_beta" }],
2025
+ entityGet: (uid: string) =>
2026
+ Promise.resolve({ uid, slug: slugs[uid] ?? uid } as unknown as EntityInfo),
2027
+ }),
2028
+ sync: vi
2029
+ .fn<(opts: SyncOptions) => Promise<SyncResult>>()
2030
+ .mockRejectedValueOnce(dnsError)
2031
+ .mockRejectedValueOnce(new Error("AccessDenied: forbidden")),
2032
+ });
2033
+
2034
+ expect(await runRunner(["--companies"], deps)).toBe(PARTIAL_SYNC_EXIT);
2035
+ expect(
2036
+ deps.stdout
2037
+ .events()
2038
+ .filter((e) => (e as { type: string }).type === "transient-network"),
2039
+ ).toHaveLength(1);
2040
+ expect(
2041
+ deps.stderr
2042
+ .events()
2043
+ .filter((e) => e.type === "error" && e.path === "(company)"),
2044
+ ).toHaveLength(1);
2045
+ });
2046
+
2047
+ it("keeps a leg errored when a path error precedes a transient throw", async () => {
2048
+ const dnsError = Object.assign(new Error("getaddrinfo ENOTFOUND vault"), {
2049
+ code: "ENOTFOUND",
2050
+ });
2051
+ const deps = makeDeps({
2052
+ createVaultClient: () =>
2053
+ makeVaultStub({
2054
+ memberships: [{ companyUid: "cmp_acme" }],
2055
+ entityGet: (uid: string) =>
2056
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
2057
+ }),
2058
+ sync: vi.fn().mockImplementation(async (opts: SyncOptions) => {
2059
+ opts.onEvent?.({
2060
+ type: "error",
2061
+ path: "locked.md",
2062
+ message: "AccessDenied: forbidden",
2063
+ });
2064
+ throw dnsError;
2065
+ }),
2066
+ });
2067
+
2068
+ expect(await runRunner(["--companies"], deps)).toBe(PARTIAL_SYNC_EXIT);
2069
+ const all = deps.stdout
2070
+ .events()
2071
+ .find((event) => event.type === "all-complete") as {
2072
+ transient: Array<{ company: string }>;
2073
+ companies: Array<{ company: string; status: string }>;
2074
+ };
2075
+ expect(all.transient).toEqual([expect.objectContaining({ company: "acme" })]);
2076
+ expect(all.companies).toEqual([
2077
+ expect.objectContaining({ company: "acme", status: "errored" }),
2078
+ ]);
1933
2079
  });
1934
2080
 
1935
2081
  /**
@@ -2270,6 +2416,37 @@ describe("all-complete aggregate", () => {
2270
2416
  .toMatchObject({ properties: { status: "partial" } });
2271
2417
  });
2272
2418
 
2419
+ it("records a transient company leg without adding an errored-leg telemetry count", async () => {
2420
+ const posts: TelemetryEventsBatch[] = [];
2421
+ const client: VaultClientSurface = {
2422
+ ...makeVaultStub({
2423
+ memberships: [{ companyUid: "cmp_a" }],
2424
+ entityGet: (uid: string) =>
2425
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
2426
+ }),
2427
+ postTelemetryEvents: vi.fn(async (batch) => {
2428
+ posts.push(batch);
2429
+ return { ok: true, written: batch.events.length, skipped: [] };
2430
+ }),
2431
+ };
2432
+ const dnsError = Object.assign(new Error("getaddrinfo ENOTFOUND vault"), {
2433
+ code: "ENOTFOUND",
2434
+ });
2435
+ const deps = makeDeps({
2436
+ createVaultClient: () => client,
2437
+ sync: vi.fn().mockRejectedValue(dnsError),
2438
+ });
2439
+
2440
+ expect(await runRunner(["--companies"], deps)).toBe(TRANSIENT_NETWORK_EXIT);
2441
+ await new Promise((resolve) => setTimeout(resolve, 0));
2442
+
2443
+ const events = posts.flatMap((post) => post.events);
2444
+ expect(events.find((event) => event.eventName === "company_sync_leg_completed"))
2445
+ .toMatchObject({ properties: { status: "transient-network" } });
2446
+ expect(events.find((event) => event.eventName === "sync_runner_completed"))
2447
+ .toMatchObject({ properties: { status: "partial", errorCount: 0 } });
2448
+ });
2449
+
2273
2450
  it("retains terminal failure telemetry when post-client discovery fails", async () => {
2274
2451
  const posts: TelemetryEventsBatch[] = [];
2275
2452
  const client: VaultClientSurface = {
@@ -2329,6 +2506,7 @@ describe("all-complete aggregate", () => {
2329
2506
  bytesUploaded: 0,
2330
2507
  conflictPaths: [],
2331
2508
  errors: [],
2509
+ transient: [],
2332
2510
  partial: false,
2333
2511
  companies: [
2334
2512
  {
@@ -3007,6 +3185,7 @@ describe("--direction", () => {
3007
3185
  bytesUploaded: 125,
3008
3186
  conflictPaths: [],
3009
3187
  errors: [],
3188
+ transient: [],
3010
3189
  partial: false,
3011
3190
  companies: [
3012
3191
  {
@@ -4491,6 +4670,50 @@ describe("runRunnerWithLoop — event-push wiring", () => {
4491
4670
  expect(watcher.disposed).toBe(true);
4492
4671
  });
4493
4672
 
4673
+ it("keeps the watch loop alive when a company leg returns transient exit 75 (HQ-SYNC-X)", async () => {
4674
+ const watcher = makeWatcherStub();
4675
+ const dnsError = Object.assign(new Error("getaddrinfo ENOTFOUND vault"), {
4676
+ code: "ENOTFOUND",
4677
+ });
4678
+ const transientPass = makeDeps({
4679
+ createVaultClient: () =>
4680
+ makeVaultStub({
4681
+ memberships: [{ companyUid: "cmp_acme" }],
4682
+ entityGet: (uid: string) =>
4683
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
4684
+ }),
4685
+ sync: vi.fn().mockRejectedValue(dnsError),
4686
+ });
4687
+ let passes = 0;
4688
+ const runPass = vi.fn().mockImplementation(async () => {
4689
+ passes += 1;
4690
+ // End the otherwise-open watch loop with a later hard failure. The first
4691
+ // pass uses the real company-leg fanout path, not a synthetic exit code.
4692
+ return passes === 1 ? runRunner(["--companies"], transientPass) : 1;
4693
+ });
4694
+
4695
+ const code = await runRunnerWithLoop(
4696
+ ["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
4697
+ {
4698
+ runPass,
4699
+ clock: new FakeClock(),
4700
+ createWatcher: () => watcher,
4701
+ sleep: () => Promise.resolve(),
4702
+ onShutdownSignal: () => () => {},
4703
+ },
4704
+ );
4705
+
4706
+ expect(runPass).toHaveBeenCalledTimes(2);
4707
+ expect(code).toBe(1);
4708
+ expect(code).not.toBe(TRANSIENT_NETWORK_EXIT);
4709
+ expect(
4710
+ transientPass.stderr
4711
+ .events()
4712
+ .some((event) => event.type === "error" && event.path === "(company)"),
4713
+ ).toBe(false);
4714
+ expect(watcher.disposed).toBe(true);
4715
+ });
4716
+
4494
4717
  it("a PARTIAL_SYNC_EXIT poll pass does NOT surface as a crash — the loop retries", async () => {
4495
4718
  // A partial pass (some per-file transfers threw: oversized object on an
4496
4719
  // old runner, presign denial, corrupt vault key) must keep
@@ -46,8 +46,9 @@
46
46
  * runs where every company completed OR cleanly returned `aborted: true`
47
47
  * (a `--on-conflict abort` policy decision is not an error).
48
48
  * 1 — argv parse error or unrecoverable pre-sync failure.
49
- * 2 — at least one company threw mid-stream (e.g. mid-fanout 401, network
50
- * reset, S3 5xx after retries). The all-complete event carries
49
+ * 2 — at least one company threw a deterministic, non-transport failure
50
+ * mid-stream (e.g. mid-fanout 401, denied access, S3 5xx after retries).
51
+ * The all-complete event carries
51
52
  * `partial: true` and per-company partial counts captured from
52
53
  * `progress` events before the throw, so consumers parsing ndjson see
53
54
  * what actually transferred. This is distinct from exit 0 with
@@ -386,6 +387,19 @@ export type RunnerEvent =
386
387
  } & Omit<Extract<SyncProgressEvent, { type: "plan" }>, "type">)
387
388
  | ({ type: "progress"; company: string } & Omit<Extract<SyncProgressEvent, { type: "progress" }>, "type">)
388
389
  | ({ type: "error"; company?: string } & Omit<Extract<SyncProgressEvent, { type: "error" }>, "type">)
390
+ | {
391
+ /**
392
+ * A per-company transport failure that is retryable, not a deterministic
393
+ * sync error. This deliberately stays off the `error` channel: exit 2
394
+ * means a real partial sync failure, while this pass returns EX_TEMPFAIL
395
+ * (75) and is retried by the watch loop. Mirrors the non-error treatment
396
+ * of deliberate skip events.
397
+ */
398
+ type: "transient-network";
399
+ company: string;
400
+ path: "(company)";
401
+ message: string;
402
+ }
389
403
  | ({ type: "conflict"; company: string } & Omit<Extract<SyncProgressEvent, { type: "conflict" }>, "type">)
390
404
  | { type: "new-files"; company: string; files: Array<{ path: string; bytes: number; addedBy: string | null }> }
391
405
  | { type: "scope-excluded"; company: string; count: number; samplePaths: string[] }
@@ -448,9 +462,16 @@ export type RunnerEvent =
448
462
  * Always emitted; empty array when no conflicts were detected. Lets
449
463
  * the menubar UI render a flat list without re-walking per-company
450
464
  * `complete` events.
451
- */
465
+ */
452
466
  conflictPaths: Array<{ company: string; path: string; direction: "pull" | "push" }>;
453
467
  errors: Array<{ company: string; message: string }>;
468
+ /**
469
+ * Retryable company-leg transport failures. Kept separate from `errors`
470
+ * so consumers can surface an incomplete pass without calling it a
471
+ * deterministic sync error. Always present for backward-compatible,
472
+ * additive protocol evolution.
473
+ */
474
+ transient: Array<{ company: string; message: string }>;
454
475
  /**
455
476
  * True when at least one company in the fanout did not complete cleanly
456
477
  * — either it returned `aborted: true` (e.g. conflict-abort) or its sync
@@ -472,10 +493,12 @@ export type RunnerEvent =
472
493
  * - "aborted" — sync returned cleanly with `aborted: true` (conflict-abort)
473
494
  * - "errored" — sync threw mid-stream; counts are sourced from progress
474
495
  * events seen before the throw
496
+ * - "transient-network" — a retryable transport failure interrupted the
497
+ * leg; see `transient` for its diagnostic
475
498
  */
476
499
  companies: Array<{
477
500
  company: string;
478
- status: "complete" | "aborted" | "errored";
501
+ status: "complete" | "aborted" | "errored" | "transient-network";
479
502
  filesDownloaded: number;
480
503
  bytesDownloaded: number;
481
504
  filesUploaded: number;
@@ -1245,8 +1268,8 @@ export async function runRunner(
1245
1268
  // full context (per-file errors → company error → exit) in a single
1246
1269
  // Sentry issue alerted to #hq-alerts.
1247
1270
  //
1248
- // Non-error events (progress, complete, fanout-plan, all-complete,
1249
- // setup-needed) stay on stdout. They're the protocol stream the menubar
1271
+ // Non-error events (progress, complete, transient-network, fanout-plan,
1272
+ // all-complete, setup-needed) stay on stdout. They're the protocol stream the menubar
1250
1273
  // parses for UI updates; mixing them with error events on the same
1251
1274
  // channel was the original design (single ndjson stream, simpler to
1252
1275
  // tee), but error context belongs in the diagnostic channel.
@@ -1559,7 +1582,7 @@ export async function runRunner(
1559
1582
  }
1560
1583
  throw err;
1561
1584
  }
1562
- const { errors, allConflicts, meaningfulOutcomeCount } = fanout;
1585
+ const { errors, transientCompanies, allConflicts, meaningfulOutcomeCount } = fanout;
1563
1586
  deps.onPassResult?.({
1564
1587
  pushPathResults: fanout.pushPathResults,
1565
1588
  companyFailures: fanout.companyFailures,
@@ -1757,6 +1780,7 @@ export async function runRunner(
1757
1780
  bytesUploaded: rollup.totalUploadedBytes,
1758
1781
  conflictPaths: allConflicts,
1759
1782
  errors,
1783
+ transient: transientCompanies,
1760
1784
  partial: rollup.partial,
1761
1785
  companies: rollup.companies,
1762
1786
  });
@@ -1954,7 +1978,9 @@ export async function runRunner(
1954
1978
  // the plan (they were omitted) and return TRANSIENT so watch scoped drain
1955
1979
  // does not publish without a successful upload pass.
1956
1980
  if (errors.length > 0) return PARTIAL_SYNC_EXIT;
1957
- if (deferredUnresolved > 0) return TRANSIENT_NETWORK_EXIT;
1981
+ if (transientCompanies.length > 0 || deferredUnresolved > 0) {
1982
+ return TRANSIENT_NETWORK_EXIT;
1983
+ }
1958
1984
  return 0;
1959
1985
  }
1960
1986