@indigoai-us/hq-cloud 6.14.42 → 6.14.44

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 (34) hide show
  1. package/dist/bin/sync-runner-company.d.ts +4 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  3. package/dist/bin/sync-runner-company.js +38 -9
  4. package/dist/bin/sync-runner-company.js.map +1 -1
  5. package/dist/bin/sync-runner-rollup.d.ts +1 -1
  6. package/dist/bin/sync-runner-rollup.d.ts.map +1 -1
  7. package/dist/bin/sync-runner.d.ts +30 -5
  8. package/dist/bin/sync-runner.d.ts.map +1 -1
  9. package/dist/bin/sync-runner.js +9 -6
  10. package/dist/bin/sync-runner.js.map +1 -1
  11. package/dist/bin/sync-runner.test.js +180 -21
  12. package/dist/bin/sync-runner.test.js.map +1 -1
  13. package/dist/cli/rescue-core.d.ts +13 -0
  14. package/dist/cli/rescue-core.d.ts.map +1 -1
  15. package/dist/cli/rescue-core.js +47 -2
  16. package/dist/cli/rescue-core.js.map +1 -1
  17. package/dist/cli/rescue-hq-root-guard.test.js +35 -1
  18. package/dist/cli/rescue-hq-root-guard.test.js.map +1 -1
  19. package/dist/lib/net-errors.d.ts +9 -6
  20. package/dist/lib/net-errors.d.ts.map +1 -1
  21. package/dist/lib/net-errors.js +9 -6
  22. package/dist/lib/net-errors.js.map +1 -1
  23. package/dist/lib/net-errors.test.js +9 -1
  24. package/dist/lib/net-errors.test.js.map +1 -1
  25. package/package.json +2 -2
  26. package/src/bin/sync-runner-company.ts +36 -9
  27. package/src/bin/sync-runner-rollup.ts +1 -1
  28. package/src/bin/sync-runner.test.ts +246 -23
  29. package/src/bin/sync-runner.ts +34 -8
  30. package/src/cli/rescue-core.ts +49 -2
  31. package/src/cli/rescue-hq-root-guard.test.ts +40 -1
  32. package/src/lib/net-errors.test.ts +13 -1
  33. package/src/lib/net-errors.ts +9 -6
  34. package/test/e2e/sync/transient-company-leg.test.ts +380 -0
@@ -435,6 +435,36 @@ export function runRescue(
435
435
  }
436
436
  }
437
437
 
438
+ /**
439
+ * True when `root` is usable as a rescue target: an absolute path that is not
440
+ * a bare drive letter. Pure + exported for tests; `platformPath` defaults to
441
+ * the host `path` module so Windows and POSIX runtimes each apply their own
442
+ * absoluteness rules (tests pass `path.win32` / `path.posix` explicitly).
443
+ *
444
+ * Rejecting bare `C:` matters even though `path.win32.isAbsolute("C:")` is
445
+ * already false — the explicit pattern documents the exact field failure and
446
+ * guards against a future helper swap that treats drive-relative paths as
447
+ * absolute.
448
+ */
449
+ export function isUsableRescueRoot(root: string, platformPath: path.PlatformPath = path): boolean {
450
+ if (/^[A-Za-z]:$/.test(root)) return false;
451
+ return platformPath.isAbsolute(root);
452
+ }
453
+
454
+ /**
455
+ * `fs.realpathSync` that converts failure into the rescue's clean error path
456
+ * (message + ExitError) instead of an uncaught exception. An uncaught throw
457
+ * here is what turned one bad `--hq-root` into a daily crash-loop on Windows.
458
+ */
459
+ function realpathOrExit(p: string, err: (s: string) => void): string {
460
+ try {
461
+ return fs.realpathSync(p);
462
+ } catch (e) {
463
+ err(`error: cannot resolve --hq-root ${p}: ${e instanceof Error ? e.message : String(e)}\n`);
464
+ throw new ExitError(1);
465
+ }
466
+ }
467
+
438
468
  function doRescue(
439
469
  cfg: Config,
440
470
  env: NodeJS.ProcessEnv,
@@ -445,16 +475,33 @@ function doRescue(
445
475
  // --- Resolve HQ root ---
446
476
  let hqRoot: string;
447
477
  if (cfg.hqRootOverride) {
478
+ // Windows field failure (2026-08-02): the menubar app's daily background
479
+ // core update crash-looped for days because `--hq-root` arrived as the
480
+ // bare drive letter `C:`. `isDir("C:")` is true (drive-relative, resolves
481
+ // against the per-drive cwd) but `fs.realpathSync("C:")` throws EISDIR,
482
+ // so every retry died with an uncaught exception — console-window
483
+ // flicker, git.exe 0xc0000142 dialogs, and no core updates ever landing.
484
+ // Fail closed with a diagnosable message instead: a rescue root must be
485
+ // an absolute directory path, and the error echoes the exact argv this
486
+ // process received so the daily log identifies the caller that mangled
487
+ // the path (the split happens upstream of this process).
488
+ if (!isUsableRescueRoot(cfg.hqRootOverride)) {
489
+ err(
490
+ `error: --hq-root must be an absolute path, got ${JSON.stringify(cfg.hqRootOverride)}.\n` +
491
+ ` argv: ${JSON.stringify(process.argv.slice(2))}\n`,
492
+ );
493
+ throw new ExitError(1);
494
+ }
448
495
  if (!isDir(cfg.hqRootOverride)) {
449
496
  // bash `cd` failure under set -e would abort; mirror as a generic error.
450
497
  err(`error: --hq-root ${cfg.hqRootOverride} is not a directory.\n`);
451
498
  throw new ExitError(1);
452
499
  }
453
- hqRoot = fs.realpathSync(cfg.hqRootOverride);
500
+ hqRoot = realpathOrExit(cfg.hqRootOverride, err);
454
501
  } else {
455
502
  // Legacy default assumed the script lived at personal/skills/<skill>/.
456
503
  // The package's programmatic callers always pass --hq-root; fall back to cwd.
457
- hqRoot = fs.realpathSync(process.cwd());
504
+ hqRoot = realpathOrExit(process.cwd(), err);
458
505
  }
459
506
 
460
507
  // HQ-root sanity gate — guards against wiping a non-HQ directory. `companies/`
@@ -23,7 +23,7 @@ import { execFileSync } from "child_process";
23
23
  import * as fs from "fs";
24
24
  import * as os from "os";
25
25
  import * as path from "path";
26
- import { runRescue } from "./rescue-core.js";
26
+ import { isUsableRescueRoot, runRescue } from "./rescue-core.js";
27
27
 
28
28
  function hasGit(): boolean {
29
29
  try {
@@ -190,4 +190,43 @@ exec ${JSON.stringify(realGit)} "$@"
190
190
  expect(r.status, out).toBe(3);
191
191
  expect(out).toContain("does not look like an HQ root");
192
192
  });
193
+
194
+ // 2026-08-02 Windows field failure: `--hq-root C:` (a path mangled upstream
195
+ // of this process) crashed with an uncaught EISDIR from fs.realpathSync on
196
+ // every daily background attempt. The gate must fail CLOSED — clean exit 1,
197
+ // no throw — and echo the received argv so the log identifies the caller.
198
+ it("rejects a bare drive letter --hq-root with a clean exit and argv echo", () => {
199
+ const r = rescueDry("C:");
200
+ const out = `${r.stdout}\n${r.stderr}`;
201
+ expect(r.status, out).toBe(1);
202
+ expect(out).toContain("--hq-root must be an absolute path");
203
+ expect(out).toContain('"C:"');
204
+ expect(out).toContain("argv:");
205
+ });
206
+
207
+ it("rejects a relative --hq-root with a clean exit", () => {
208
+ const r = rescueDry("some/relative/dir");
209
+ const out = `${r.stdout}\n${r.stderr}`;
210
+ expect(r.status, out).toBe(1);
211
+ expect(out).toContain("--hq-root must be an absolute path");
212
+ });
213
+ });
214
+
215
+ describe("isUsableRescueRoot (pure)", () => {
216
+ it("rejects bare drive letters under both path flavors", () => {
217
+ expect(isUsableRescueRoot("C:", path.win32)).toBe(false);
218
+ expect(isUsableRescueRoot("z:", path.win32)).toBe(false);
219
+ expect(isUsableRescueRoot("C:", path.posix)).toBe(false);
220
+ });
221
+
222
+ it("accepts absolute roots for the matching platform", () => {
223
+ expect(isUsableRescueRoot("C:\\Users\\caio\\HQ", path.win32)).toBe(true);
224
+ expect(isUsableRescueRoot("/Users/caio/HQ", path.posix)).toBe(true);
225
+ });
226
+
227
+ it("rejects relative and drive-relative paths", () => {
228
+ expect(isUsableRescueRoot("Users\\caio\\HQ", path.win32)).toBe(false);
229
+ expect(isUsableRescueRoot("C:Users\\caio\\HQ", path.win32)).toBe(false);
230
+ expect(isUsableRescueRoot("some/dir", path.posix)).toBe(false);
231
+ });
193
232
  });
@@ -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
+ });