@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.
- package/dist/bin/sync-runner-company.d.ts +4 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +38 -9
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-rollup.d.ts +1 -1
- package/dist/bin/sync-runner-rollup.d.ts.map +1 -1
- package/dist/bin/sync-runner.d.ts +30 -5
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +9 -6
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +180 -21
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +25 -3
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +357 -1
- package/dist/cli/share.test.js.map +1 -1
- package/dist/lib/net-errors.d.ts +9 -6
- package/dist/lib/net-errors.d.ts.map +1 -1
- package/dist/lib/net-errors.js +9 -6
- package/dist/lib/net-errors.js.map +1 -1
- package/dist/lib/net-errors.test.js +9 -1
- package/dist/lib/net-errors.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +36 -9
- package/src/bin/sync-runner-rollup.ts +1 -1
- package/src/bin/sync-runner.test.ts +246 -23
- package/src/bin/sync-runner.ts +34 -8
- package/src/cli/share.test.ts +446 -1
- package/src/cli/share.ts +27 -3
- package/src/lib/net-errors.test.ts +13 -1
- package/src/lib/net-errors.ts +9 -6
- package/test/e2e/sync/transient-company-leg.test.ts +380 -0
package/dist/lib/net-errors.d.ts
CHANGED
|
@@ -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
|
|
6
|
-
* machine is briefly offline (wifi drop,
|
|
7
|
-
*
|
|
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.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
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"}
|
package/dist/lib/net-errors.js
CHANGED
|
@@ -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
|
|
6
|
-
* machine is briefly offline (wifi drop,
|
|
7
|
-
*
|
|
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.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
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
|
|
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":"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,
|
|
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
|
@@ -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
|
-
|
|
493
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
*
|
|
1879
|
-
*
|
|
1880
|
-
*
|
|
1881
|
-
*
|
|
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("
|
|
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(
|
|
1912
|
+
expect(code).toBe(TRANSIENT_NETWORK_EXIT);
|
|
1918
1913
|
|
|
1919
|
-
|
|
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 === "
|
|
1924
|
-
)
|
|
1925
|
-
|
|
1926
|
-
expect(
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
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
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -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
|
|
50
|
-
*
|
|
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,
|
|
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)
|
|
1981
|
+
if (transientCompanies.length > 0 || deferredUnresolved > 0) {
|
|
1982
|
+
return TRANSIENT_NETWORK_EXIT;
|
|
1983
|
+
}
|
|
1958
1984
|
return 0;
|
|
1959
1985
|
}
|
|
1960
1986
|
|