@indigoai-us/hq-cloud 6.14.42 → 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.
@@ -8,7 +8,7 @@ describe("isTransientNetworkError", () => {
8
8
  expect(isTransientNetworkError(new TypeError("fetch failed"))).toBe(true);
9
9
  });
10
10
 
11
- it("treats known transport `.code`s as transient", () => {
11
+ it("treats direct transport `.code`s as transient", () => {
12
12
  for (const code of ["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ETIMEDOUT"]) {
13
13
  const err = Object.assign(new Error("boom"), { code });
14
14
  expect(isTransientNetworkError(err)).toBe(true);
@@ -23,6 +23,18 @@ describe("isTransientNetworkError", () => {
23
23
  expect(isTransientNetworkError(err)).toBe(true);
24
24
  });
25
25
 
26
+ it("walks an AWS SDK UnknownError cause chain to the underlying DNS error", () => {
27
+ const cause = Object.assign(
28
+ new Error("getaddrinfo ENOTFOUND hq-vault-prs-test.s3.us-east-1.amazonaws.com"),
29
+ { code: "ENOTFOUND", syscall: "getaddrinfo" },
30
+ );
31
+ const err = Object.assign(new Error("UnknownError"), {
32
+ name: "UnknownError",
33
+ cause,
34
+ });
35
+ expect(isTransientNetworkError(err)).toBe(true);
36
+ });
37
+
26
38
  it("walks AggregateError.errors (happy-eyeballs)", () => {
27
39
  const agg = Object.assign(new AggregateError([
28
40
  Object.assign(new Error("v4"), { code: "ECONNREFUSED" }),
@@ -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
  /**
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Artifact-level regression for HQ-SYNC-X.
3
+ *
4
+ * This starts the compiled runner as a child process. The local vault stub
5
+ * completes authentication, membership discovery, entity lookup, and the
6
+ * normal company-vault list. Only the personal vault's direct-S3 endpoint uses
7
+ * an unresolvable `.invalid` host. That makes the real sync function reject
8
+ * from its fanout leg, rather than synthesizing a per-file error or failing at
9
+ * the initial GET /membership/me boundary (which already returns 75).
10
+ */
11
+
12
+ import { createServer, type Server } from "node:http";
13
+ import { once } from "node:events";
14
+ import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
16
+ import path from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { spawn } from "node:child_process";
19
+
20
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
21
+
22
+ const repoRoot = fileURLToPath(new URL("../../../", import.meta.url));
23
+ const artifactPath = process.env.HQ_CLOUD_E2E_ARTIFACT
24
+ ? path.resolve(process.env.HQ_CLOUD_E2E_ARTIFACT)
25
+ : path.join(repoRoot, "dist", "bin", "sync-runner.js");
26
+
27
+ const companyUid = "cmp_clean_e2e";
28
+ const companySlug = "clean-e2e";
29
+ const personalUid = "prs_transient_e2e";
30
+ const invalidS3Endpoint = "http://hq-vault-transient-company-leg.invalid";
31
+
32
+ let fixtureRoot: string | undefined;
33
+ let hqRoot: string;
34
+ let testHome: string;
35
+ let server: Server | undefined;
36
+ let vaultUrl: string;
37
+ const requests: string[] = [];
38
+ let personListReads = 0;
39
+
40
+ function json(response: import("node:http").ServerResponse, body: unknown): void {
41
+ response.writeHead(200, { "content-type": "application/json" });
42
+ response.end(JSON.stringify(body));
43
+ }
44
+
45
+ async function ensureArtifact(): Promise<void> {
46
+ try {
47
+ await access(artifactPath);
48
+ return;
49
+ } catch (err) {
50
+ // An explicit artifact path is used only by the base-commit proof. Do not
51
+ // hide a bad path there by building the candidate artifact instead.
52
+ if (process.env.HQ_CLOUD_E2E_ARTIFACT) throw err;
53
+ }
54
+
55
+ await new Promise<void>((resolve, reject) => {
56
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
57
+ const child = spawn(npm, ["run", "build"], {
58
+ cwd: repoRoot,
59
+ stdio: "inherit",
60
+ });
61
+ child.once("error", reject);
62
+ child.once("close", (code) => {
63
+ if (code === 0) resolve();
64
+ else reject(new Error(`npm run build exited ${code ?? "without a status"}`));
65
+ });
66
+ });
67
+ await access(artifactPath);
68
+ }
69
+
70
+ async function runArtifact(): Promise<{
71
+ code: number | null;
72
+ stdout: string;
73
+ stderr: string;
74
+ }> {
75
+ return new Promise((resolve, reject) => {
76
+ const child = spawn(
77
+ process.execPath,
78
+ [artifactPath, "--companies", "--hq-root", hqRoot, "--json"],
79
+ {
80
+ cwd: path.dirname(artifactPath),
81
+ env: {
82
+ ...process.env,
83
+ HOME: testHome,
84
+ HQ_MACHINE_ID: "e2e-transient-company-leg",
85
+ HQ_QMD_REINDEX_ON_SYNC: "0",
86
+ HQ_STATE_DIR: path.join(fixtureRoot, "state"),
87
+ HQ_VAULT_API_URL: vaultUrl,
88
+ AWS_ENDPOINT_URL: invalidS3Endpoint,
89
+ },
90
+ stdio: ["ignore", "pipe", "pipe"],
91
+ },
92
+ );
93
+ let stdout = "";
94
+ let stderr = "";
95
+ child.stdout.on("data", (chunk: Buffer) => {
96
+ stdout += chunk.toString();
97
+ });
98
+ child.stderr.on("data", (chunk: Buffer) => {
99
+ stderr += chunk.toString();
100
+ });
101
+ const timeout = setTimeout(() => {
102
+ child.kill("SIGTERM");
103
+ reject(new Error("compiled sync runner did not exit within 15 seconds"));
104
+ }, 15_000);
105
+ child.once("error", (err) => {
106
+ clearTimeout(timeout);
107
+ reject(err);
108
+ });
109
+ child.once("close", (code) => {
110
+ clearTimeout(timeout);
111
+ resolve({ code, stdout, stderr });
112
+ });
113
+ });
114
+ }
115
+
116
+ async function runWatchArtifact(): Promise<{
117
+ code: number | null;
118
+ signal: NodeJS.Signals | null;
119
+ stdout: string;
120
+ stderr: string;
121
+ }> {
122
+ return new Promise((resolve, reject) => {
123
+ const child = spawn(
124
+ process.execPath,
125
+ [
126
+ artifactPath,
127
+ "--companies",
128
+ "--hq-root",
129
+ hqRoot,
130
+ "--json",
131
+ "--watch",
132
+ "--poll-remote-ms",
133
+ "25",
134
+ ],
135
+ {
136
+ cwd: path.dirname(artifactPath),
137
+ env: {
138
+ ...process.env,
139
+ HOME: testHome,
140
+ HQ_MACHINE_ID: "e2e-transient-company-leg",
141
+ HQ_QMD_REINDEX_ON_SYNC: "0",
142
+ HQ_STATE_DIR: path.join(fixtureRoot, "state"),
143
+ HQ_VAULT_API_URL: vaultUrl,
144
+ AWS_ENDPOINT_URL: invalidS3Endpoint,
145
+ },
146
+ stdio: ["ignore", "pipe", "pipe"],
147
+ },
148
+ );
149
+ let stdout = "";
150
+ let stderr = "";
151
+ let stopping = false;
152
+ const stop = () => {
153
+ if (stopping || child.exitCode !== null) return;
154
+ stopping = true;
155
+ child.kill("SIGTERM");
156
+ };
157
+ const timeout = setTimeout(() => {
158
+ stop();
159
+ reject(new Error("watching compiled sync runner did not complete two polls within 20 seconds"));
160
+ }, 20_000);
161
+ child.stdout.on("data", (chunk: Buffer) => {
162
+ stdout += chunk.toString();
163
+ const completePasses = (stdout.match(/"type":"all-complete"/g) ?? []).length;
164
+ if (completePasses >= 2) stop();
165
+ });
166
+ child.stderr.on("data", (chunk: Buffer) => {
167
+ stderr += chunk.toString();
168
+ });
169
+ child.once("error", (err) => {
170
+ clearTimeout(timeout);
171
+ reject(err);
172
+ });
173
+ child.once("close", (code, signal) => {
174
+ clearTimeout(timeout);
175
+ resolve({ code, signal, stdout, stderr });
176
+ });
177
+ });
178
+ }
179
+
180
+ beforeEach(async () => {
181
+ await ensureArtifact();
182
+ fixtureRoot = await mkdtemp(path.join(tmpdir(), "hqcloud-transient-company-leg-e2e-"));
183
+ hqRoot = path.join(fixtureRoot, "hq");
184
+ testHome = path.join(fixtureRoot, "home");
185
+ await mkdir(path.join(hqRoot, "companies", companySlug), { recursive: true });
186
+ await mkdir(path.join(testHome, ".hq"), { recursive: true });
187
+ const fakeJwtPayload = Buffer.from(
188
+ JSON.stringify({ sub: "e2e-subject", exp: Math.floor(Date.now() / 1000) + 3600 }),
189
+ ).toString("base64url");
190
+ await writeFile(
191
+ path.join(testHome, ".hq", "cognito-tokens.json"),
192
+ JSON.stringify({
193
+ accessToken: `header.${fakeJwtPayload}.signature`,
194
+ idToken: `header.${fakeJwtPayload}.signature`,
195
+ refreshToken: "e2e-only",
196
+ expiresAt: Date.now() + 60 * 60 * 1000,
197
+ tokenType: "Bearer",
198
+ }),
199
+ );
200
+
201
+ requests.length = 0;
202
+ personListReads = 0;
203
+ server = createServer((request, response) => {
204
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
205
+ requests.push(`${request.method} ${url.pathname}`);
206
+ if (request.method === "GET" && url.pathname === "/membership/me") {
207
+ json(response, { memberships: [{ companyUid }] });
208
+ return;
209
+ }
210
+ if (request.method === "GET" && url.pathname === `/entity/${companyUid}`) {
211
+ json(response, {
212
+ entity: {
213
+ uid: companyUid,
214
+ slug: companySlug,
215
+ type: "company",
216
+ bucketName: "hq-vault-e2e",
217
+ status: "active",
218
+ },
219
+ });
220
+ return;
221
+ }
222
+ if (request.method === "GET" && url.pathname === `/entity/${personalUid}`) {
223
+ json(response, {
224
+ entity: {
225
+ uid: personalUid,
226
+ slug: "personal-e2e",
227
+ type: "person",
228
+ bucketName: "hq-vault-e2e-personal",
229
+ status: "active",
230
+ },
231
+ });
232
+ return;
233
+ }
234
+ if (request.method === "GET" && url.pathname === "/v1/files/list") {
235
+ json(response, {
236
+ objects: [],
237
+ cursor: null,
238
+ truncated: false,
239
+ });
240
+ return;
241
+ }
242
+ if (request.method === "POST" && url.pathname === "/sts/vend-self") {
243
+ json(response, {
244
+ credentials: {
245
+ accessKeyId: "e2e-access-key",
246
+ secretAccessKey: "e2e-secret-key",
247
+ sessionToken: "e2e-session-token",
248
+ expiration: "2026-07-30T01:00:00.000Z",
249
+ },
250
+ expiresAt: "2026-07-30T01:00:00.000Z",
251
+ });
252
+ return;
253
+ }
254
+ if (request.method === "GET" && url.pathname === "/entity/by-type/person") {
255
+ personListReads += 1;
256
+ json(response, {
257
+ entities:
258
+ personListReads % 2 === 1
259
+ ? [
260
+ {
261
+ uid: personalUid,
262
+ slug: "personal-e2e",
263
+ type: "person",
264
+ bucketName: "hq-vault-e2e-personal",
265
+ status: "active",
266
+ },
267
+ ]
268
+ : [],
269
+ });
270
+ return;
271
+ }
272
+ if (request.method === "GET" && url.pathname === "/membership/pending-by-email") {
273
+ json(response, { invites: [] });
274
+ return;
275
+ }
276
+ if (request.method === "POST") {
277
+ json(response, { ok: true, written: 0, skipped: [] });
278
+ return;
279
+ }
280
+ response.writeHead(404, { "content-type": "application/json" });
281
+ response.end(JSON.stringify({ error: `unexpected ${request.method} ${url.pathname}` }));
282
+ });
283
+ server.listen(0, "127.0.0.1");
284
+ await once(server, "listening");
285
+ const address = server.address();
286
+ if (!address || typeof address === "string") {
287
+ throw new Error("e2e vault stub did not bind a TCP port");
288
+ }
289
+ vaultUrl = `http://127.0.0.1:${address.port}`;
290
+ });
291
+
292
+ afterEach(async () => {
293
+ if (server?.listening) {
294
+ server.close();
295
+ await once(server, "close");
296
+ }
297
+ if (fixtureRoot) {
298
+ await rm(fixtureRoot, { recursive: true, force: true });
299
+ }
300
+ });
301
+
302
+ describe("compiled sync runner transient company leg (HQ-SYNC-X)", () => {
303
+ it(
304
+ "returns 75 and emits transient-network when only the personal vault S3 leg cannot resolve",
305
+ async () => {
306
+ const result = await runArtifact();
307
+
308
+ expect(
309
+ result.code,
310
+ `stdout=${result.stdout}\nstderr=${result.stderr}`,
311
+ ).toBe(75);
312
+ expect(requests).toEqual(
313
+ expect.arrayContaining([
314
+ "GET /membership/me",
315
+ `GET /entity/${companyUid}`,
316
+ "GET /v1/files/list",
317
+ "POST /sts/vend-self",
318
+ ]),
319
+ );
320
+ const stdoutEvents = result.stdout
321
+ .trim()
322
+ .split("\n")
323
+ .filter(Boolean)
324
+ .map((line) => JSON.parse(line) as Record<string, unknown>);
325
+ expect(
326
+ stdoutEvents.filter((event) => event.type === "transient-network"),
327
+ ).toEqual([
328
+ expect.objectContaining({
329
+ company: "personal",
330
+ path: "(company)",
331
+ message: expect.stringContaining("ENOTFOUND"),
332
+ }),
333
+ ]);
334
+ expect(
335
+ stdoutEvents.some(
336
+ (event) => event.type === "error" && event.path === "(company)",
337
+ ),
338
+ ).toBe(false);
339
+ expect(stdoutEvents.find((event) => event.type === "all-complete")).toEqual(
340
+ expect.objectContaining({
341
+ errors: [],
342
+ transient: [expect.objectContaining({ company: "personal" })],
343
+ }),
344
+ );
345
+ expect(result.stderr).not.toContain('"path":"(company)"');
346
+ },
347
+ 30_000,
348
+ );
349
+
350
+ it(
351
+ "stays alive through two transient company-leg passes until SIGTERM stops it",
352
+ async () => {
353
+ const result = await runWatchArtifact();
354
+ expect(
355
+ { code: result.code, signal: result.signal },
356
+ `stdout=${result.stdout}\nstderr=${result.stderr}`,
357
+ ).toEqual({ code: null, signal: "SIGTERM" });
358
+
359
+ const stdoutEvents = result.stdout
360
+ .trim()
361
+ .split("\n")
362
+ .filter(Boolean)
363
+ .map((line) => JSON.parse(line) as Record<string, unknown>);
364
+ expect(
365
+ stdoutEvents.filter((event) => event.type === "all-complete"),
366
+ ).toHaveLength(2);
367
+ expect(
368
+ stdoutEvents.filter(
369
+ (event) =>
370
+ event.type === "transient-network" && event.company === "personal",
371
+ ),
372
+ ).toHaveLength(2);
373
+ expect(result.stderr).toContain(
374
+ "watch pass skipped — transient network failure; retrying next poll",
375
+ );
376
+ expect(result.stderr).not.toContain('"path":"(company)"');
377
+ },
378
+ 30_000,
379
+ );
380
+ });