@indigoai-us/hq-cloud 6.13.3 → 6.13.5

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 (51) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +15 -0
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner-rollup.d.ts +7 -0
  5. package/dist/bin/sync-runner-rollup.d.ts.map +1 -1
  6. package/dist/bin/sync-runner-rollup.js +15 -0
  7. package/dist/bin/sync-runner-rollup.js.map +1 -1
  8. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  9. package/dist/bin/sync-runner-watch-loop.js +10 -1
  10. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  11. package/dist/bin/sync-runner.d.ts +7 -0
  12. package/dist/bin/sync-runner.d.ts.map +1 -1
  13. package/dist/bin/sync-runner.js +36 -7
  14. package/dist/bin/sync-runner.js.map +1 -1
  15. package/dist/bin/sync-runner.test.js +142 -0
  16. package/dist/bin/sync-runner.test.js.map +1 -1
  17. package/dist/cli/share.js +15 -2
  18. package/dist/cli/share.js.map +1 -1
  19. package/dist/cli/share.test.js +39 -0
  20. package/dist/cli/share.test.js.map +1 -1
  21. package/dist/cli/sync.d.ts +22 -0
  22. package/dist/cli/sync.d.ts.map +1 -1
  23. package/dist/cli/sync.js +14 -0
  24. package/dist/cli/sync.js.map +1 -1
  25. package/dist/lib/net-errors.d.ts +36 -0
  26. package/dist/lib/net-errors.d.ts.map +1 -0
  27. package/dist/lib/net-errors.js +81 -0
  28. package/dist/lib/net-errors.js.map +1 -0
  29. package/dist/lib/net-errors.test.d.ts +2 -0
  30. package/dist/lib/net-errors.test.d.ts.map +1 -0
  31. package/dist/lib/net-errors.test.js +47 -0
  32. package/dist/lib/net-errors.test.js.map +1 -0
  33. package/dist/qmd-reindex.d.ts +14 -0
  34. package/dist/qmd-reindex.d.ts.map +1 -1
  35. package/dist/qmd-reindex.js +93 -19
  36. package/dist/qmd-reindex.js.map +1 -1
  37. package/dist/qmd-reindex.test.js +47 -0
  38. package/dist/qmd-reindex.test.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/bin/sync-runner-company.ts +21 -0
  41. package/src/bin/sync-runner-rollup.ts +23 -0
  42. package/src/bin/sync-runner-watch-loop.ts +11 -1
  43. package/src/bin/sync-runner.test.ts +167 -0
  44. package/src/bin/sync-runner.ts +49 -9
  45. package/src/cli/share.test.ts +46 -0
  46. package/src/cli/share.ts +14 -2
  47. package/src/cli/sync.ts +40 -0
  48. package/src/lib/net-errors.test.ts +53 -0
  49. package/src/lib/net-errors.ts +83 -0
  50. package/src/qmd-reindex.test.ts +50 -0
  51. package/src/qmd-reindex.ts +124 -18
@@ -104,9 +104,16 @@ import { HQ_CLOUD_VERSION } from "../version.js";
104
104
  import { collectAndSendTelemetry } from "../telemetry.js";
105
105
  import { collectAndSendSkillTelemetry } from "../skill-telemetry.js";
106
106
  import { reindexAfterSync } from "../qmd-reindex.js";
107
+ import type { ReindexOptions as QmdReindexOptions } from "../qmd-reindex.js";
108
+ import { reindex as defaultReindex } from "../cli/reindex.js";
109
+ import type { ReindexOptions, ReindexResult } from "../cli/reindex.js";
107
110
  import { pruneConflictIndex } from "../lib/conflict-index.js";
108
111
  import { materializeCodexAgents } from "../agent-codex-instructions.js";
109
112
  import { getOrCreateMachineId } from "../lib/machine-id.js";
113
+ import {
114
+ isTransientNetworkError,
115
+ TRANSIENT_NETWORK_EXIT,
116
+ } from "../lib/net-errors.js";
110
117
  import { describeError } from "../lib/describe-error.js";
111
118
  import type { Clock, TreeChangeBatch } from "../watcher.js";
112
119
  import type { PushReceiver, SyncEngineFn } from "../sync/push-receiver.js";
@@ -577,6 +584,13 @@ export interface RunnerDeps {
577
584
  createVaultClient?: (config: VaultServiceConfig) => VaultClientSurface;
578
585
  /** Sync function. Defaults to `cli/sync.sync`. */
579
586
  sync?: (options: SyncOptions) => Promise<SyncResult>;
587
+ /** Native post-fanout reindex function. Defaults to `cli/reindex.reindex`. */
588
+ reindex?: (options: ReindexOptions) => ReindexResult;
589
+ /** QMD post-sync reindex function. Defaults to `qmd-reindex.reindexAfterSync`. */
590
+ qmdReindex?: (
591
+ hqRoot: string,
592
+ options: QmdReindexOptions,
593
+ ) => ReturnType<typeof reindexAfterSync>;
580
594
  /** Internal: set when runRunner is invoked under the per-root operation lock. */
581
595
  operationLockAlreadyHeld?: boolean;
582
596
  /** Share function (push phase). Defaults to `cli/share.share`. */
@@ -622,6 +636,11 @@ function defaultGetIdTokenClaims(): IdTokenClaims | null {
622
636
  return decodeJwtClaims(tokens.idToken);
623
637
  }
624
638
 
639
+ function isKnowledgeMarkdownPath(relPath: string): boolean {
640
+ const normalized = relPath.split(path.sep).join("/");
641
+ return /^companies\/[^/]+\/knowledge\/.+\.md$/i.test(normalized);
642
+ }
643
+
625
644
  /**
626
645
  * Best-effort: claim any email-keyed pending invites that were sent before
627
646
  * this user had a person entity. Mirrors the installer's vault-handoff flow.
@@ -1145,15 +1164,19 @@ export async function runRunner(
1145
1164
  });
1146
1165
  return 0;
1147
1166
  }
1148
- // Any other failure is unrecoverable — surface as an error event and
1149
- // exit non-zero so the spawner knows the runner didn't get far enough
1150
- // to emit a useful protocol stream.
1167
+ // Surface the failure as an error event for observability either way.
1151
1168
  emit({
1152
1169
  type: "error",
1153
1170
  message: err instanceof Error ? err.message : String(err),
1154
1171
  path: "(discovery)",
1155
1172
  });
1156
- return 1;
1173
+ // A transient network failure (offline, DNS blip, vault API briefly
1174
+ // unreachable) is NOT a crash — it self-heals on the next poll. Return the
1175
+ // retryable exit code so the watch loop stays alive instead of exiting and
1176
+ // being reported as "watcher exited unexpectedly (code=Some(1))" for every
1177
+ // blip (HQ-SYNC-1W). A one-shot run still exits non-zero. Any other failure
1178
+ // is treated as a hard error (exit 1), as before.
1179
+ return isTransientNetworkError(err) ? TRANSIENT_NETWORK_EXIT : 1;
1157
1180
  }
1158
1181
 
1159
1182
  const targetPlan = await buildFanoutPlan({
@@ -1200,6 +1223,23 @@ export async function runRunner(
1200
1223
  const { errors, allConflicts } = fanout;
1201
1224
  const rollup = rollupAllComplete(plan, fanout.stateByCompany);
1202
1225
 
1226
+ if (rollup.needsReindex) {
1227
+ try {
1228
+ (deps.reindex ?? defaultReindex)({
1229
+ repoRoot: parsed.hqRoot,
1230
+ ...(deps.operationLockAlreadyHeld === true ? { skipLock: true } : {}),
1231
+ });
1232
+ } catch (err) {
1233
+ reportDiagnostic({
1234
+ component: "native-reindex",
1235
+ event: "runner.native_reindex.failed",
1236
+ message: "post-fanout native reindex failed",
1237
+ err,
1238
+ context: { hqRoot: parsed.hqRoot },
1239
+ });
1240
+ }
1241
+ }
1242
+
1203
1243
  // Fire telemetry collector before the all-complete emit so the cursor at
1204
1244
  // `~/.hq/telemetry-cursor.json` is consistent with what the menubar sees.
1205
1245
  await awaitTelemetryWithTimeout(
@@ -1250,12 +1290,12 @@ export async function runRunner(
1250
1290
  // pulled in (nothing to reindex otherwise) and not explicitly disabled.
1251
1291
  // Self-contained: shells out to the global `qmd` binary, no dependency on
1252
1292
  // any (possibly stale) script inside the synced HQ tree. See qmd-reindex.ts.
1253
- if (
1254
- rollup.totalDownloaded > 0 &&
1255
- process.env.HQ_QMD_REINDEX_ON_SYNC !== "0"
1256
- ) {
1293
+ const qmdChangedPaths = rollup.changedPaths.filter(isKnowledgeMarkdownPath);
1294
+ if (qmdChangedPaths.length > 0 && process.env.HQ_QMD_REINDEX_ON_SYNC !== "0") {
1257
1295
  try {
1258
- reindexAfterSync(parsed.hqRoot, {
1296
+ (deps.qmdReindex ?? reindexAfterSync)(parsed.hqRoot, {
1297
+ changedPaths: qmdChangedPaths,
1298
+ forceCollectionRefresh: true,
1259
1299
  log: ({ event, message, err, context }) =>
1260
1300
  reportDiagnostic({
1261
1301
  component: "qmd-reindex",
@@ -1264,6 +1264,52 @@ describe("share", () => {
1264
1264
  expect(events.some((e) => e.type === "error")).toBe(false);
1265
1265
  });
1266
1266
 
1267
+ it("a file over the size limit is a benign skip-size-limit event, NEVER an error (HQ-SYNC-4 exit-2 flood)", async () => {
1268
+ // Regression for the fleet-wide "auto-sync watcher exited unexpectedly
1269
+ // (code=Some(2))" flood (HQ-SYNC-4, ~93k events across 60+ machines). An
1270
+ // oversized file is a permanent, benign skip — it exceeds the 50 MB cap on
1271
+ // EVERY pass, forever. Emitting it as `type: "error"` pushed it into the
1272
+ // runner's `errors[]`, so the pass returned exit 2, which the menubar
1273
+ // supervisor reported as an unexpected watcher crash on every single tick.
1274
+ // A too-big file must surface for visibility but must NOT count as an error.
1275
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1276
+ fs.mkdirSync(companyRoot, { recursive: true });
1277
+ const bigFile = path.join(companyRoot, "huge.mp4");
1278
+ // Sparse 51 MB file: statSync (isWithinSizeLimit) reports 51 MB while the
1279
+ // file occupies ~0 bytes on disk, so the test stays fast.
1280
+ const fd = fs.openSync(bigFile, "w");
1281
+ fs.ftruncateSync(fd, 51 * 1024 * 1024);
1282
+ fs.closeSync(fd);
1283
+
1284
+ const events: Array<{ type?: string; path?: string; bytes?: number }> = [];
1285
+ const result = await share({
1286
+ paths: [bigFile],
1287
+ company: "acme",
1288
+ vaultConfig: mockConfig,
1289
+ hqRoot: tmpDir,
1290
+ onEvent: (e) => events.push(e as { type?: string }),
1291
+ });
1292
+
1293
+ // Not uploaded, counted as a skip, company did not abort.
1294
+ expect(result.aborted).toBe(false);
1295
+ expect(result.filesUploaded).toBe(0);
1296
+ expect(result.filesSkipped).toBe(1);
1297
+ expect(uploadFile).not.toHaveBeenCalled();
1298
+
1299
+ // The oversized file surfaces as a benign skip-size-limit event for
1300
+ // visibility (path + byte size)…
1301
+ const skipEv = events.find((e) => e.type === "skip-size-limit") as
1302
+ | { path: string; bytes: number }
1303
+ | undefined;
1304
+ expect(skipEv).toBeDefined();
1305
+ expect(skipEv!.path).toBe("huge.mp4");
1306
+ expect(skipEv!.bytes).toBe(51 * 1024 * 1024);
1307
+
1308
+ // …and CRUCIALLY never as an error — an error event is what flipped the
1309
+ // pass to exit 2 and drove the watcher-crash flood.
1310
+ expect(events.some((e) => e.type === "error")).toBe(false);
1311
+ });
1312
+
1267
1313
  it("uploads (no conflict) when only the local side changed since last sync", async () => {
1268
1314
  // Regression for hq-cloud#<conflict-detection>: a local edit to a file
1269
1315
  // that exists on S3 used to trigger a push conflict because the
package/src/cli/share.ts CHANGED
@@ -1135,10 +1135,22 @@ async function executeUploads(
1135
1135
  const uploadItems: UploadPlanItem[] = [];
1136
1136
  for (const item of pushPlan.items) {
1137
1137
  if (item.action === "skip-size-limit") {
1138
+ // A file over the size cap is a permanent, benign skip — NOT an error.
1139
+ // Emitting it as `type: "error"` pushed it into the runner's `errors[]`,
1140
+ // so every watch pass returned exit 2 and the menubar reported an
1141
+ // "auto-sync watcher exited unexpectedly (code=Some(2))" crash on every
1142
+ // tick (the fleet-wide HQ-SYNC-4 flood). Surface it for visibility, but
1143
+ // do not let it flip the pass to a non-zero exit.
1144
+ let bytes = 0;
1145
+ try {
1146
+ bytes = fs.statSync(item.absolutePath).size;
1147
+ } catch {
1148
+ // Best-effort size for display only; a stat race must not break the sync.
1149
+ }
1138
1150
  run.emit({
1139
- type: "error",
1151
+ type: "skip-size-limit",
1140
1152
  path: item.relativePath,
1141
- message: "file exceeds size limit",
1153
+ bytes,
1142
1154
  });
1143
1155
  counters.filesSkipped++;
1144
1156
  continue;
package/src/cli/sync.ts CHANGED
@@ -322,6 +322,24 @@ export type SyncProgressEvent =
322
322
  type: "upload-suppressed-tombstone";
323
323
  path: string;
324
324
  deletedAt: string;
325
+ }
326
+ | {
327
+ /**
328
+ * Emitted by the PUSH leg (`share()`) once per local file skipped because
329
+ * it exceeds the max sync size (`isWithinSizeLimit`, 50 MB default). This
330
+ * is a permanent, benign, user-policy outcome — the file is too big to
331
+ * sync and will be over the cap on every pass — so it is NOT an error and
332
+ * the run exits 0. It was previously emitted as `type: "error"`, which
333
+ * pushed it into the runner's `errors[]` and made every watch pass return
334
+ * exit 2; the menubar supervisor then reported that as
335
+ * "auto-sync watcher exited unexpectedly (code=Some(2))" on every tick —
336
+ * the fleet-wide HQ-SYNC-4 flood. Surfaced here purely for visibility
337
+ * (`bytes` is the offending file's size) so a UI/CLI can tell the user
338
+ * which file was too big without treating it as a failure.
339
+ */
340
+ type: "skip-size-limit";
341
+ path: string;
342
+ bytes: number;
325
343
  };
326
344
 
327
345
  export interface SyncOptions {
@@ -503,6 +521,11 @@ export interface SyncResult {
503
521
  * true they are left on disk and tombstoned, and counted here.
504
522
  */
505
523
  scopeOrphansBlocked: number;
524
+ /**
525
+ * Remote keys whose local on-disk counterpart changed during this pull.
526
+ * Includes downloads, tombstone deletes, and clean scope-orphan removals.
527
+ */
528
+ changedPaths?: string[];
506
529
  }
507
530
 
508
531
  type SyncEventEmitter = (event: SyncProgressEvent) => void;
@@ -535,6 +558,7 @@ interface PullCounters {
535
558
  filesTombstoned: number;
536
559
  filesOutOfScope: number;
537
560
  conflictPaths: string[];
561
+ changedPaths: string[];
538
562
  }
539
563
 
540
564
  interface ScopeShrinkPlanState {
@@ -549,6 +573,7 @@ interface ScopeShrinkRun {
549
573
  shrinkPlan: ReturnType<typeof buildScopeShrinkPlan>;
550
574
  shrinkResult: ReturnType<typeof applyScopeShrink>;
551
575
  scopeOrphansRemoved: number;
576
+ changedPaths: string[];
552
577
  }
553
578
 
554
579
  /**
@@ -831,6 +856,7 @@ function createPullCounters(): PullCounters {
831
856
  filesTombstoned: 0,
832
857
  filesOutOfScope: 0,
833
858
  conflictPaths: [],
859
+ changedPaths: [],
834
860
  };
835
861
  }
836
862
 
@@ -943,6 +969,10 @@ function executeScopeShrink(
943
969
  shrinkResult,
944
970
  scopeOrphansRemoved:
945
971
  shrinkResult.cleanRemoved + shrinkResult.cleanQuarantined,
972
+ changedPaths: [
973
+ ...shrinkResult.quarantinedPaths,
974
+ ...shrinkResult.removedPaths,
975
+ ],
946
976
  };
947
977
  }
948
978
 
@@ -1039,6 +1069,7 @@ function executeFileTombstoneDelete(
1039
1069
  }
1040
1070
  removeEntry(run.journal, tombstoneKey);
1041
1071
  counters.filesTombstoned++;
1072
+ counters.changedPaths.push(tombstoneKey);
1042
1073
  run.emit({ type: "progress", path: tombstoneKey, bytes: 0 });
1043
1074
  }
1044
1075
 
@@ -1198,6 +1229,7 @@ async function executeConflictItem(
1198
1229
  filesOutOfScope: counters.filesOutOfScope,
1199
1230
  scopeOrphansRemoved: scopeRun.scopeOrphansRemoved,
1200
1231
  scopeOrphansBlocked: scopeRun.shrinkResult.dirtyTombstoned,
1232
+ changedPaths: [...counters.changedPaths, ...scopeRun.changedPaths],
1201
1233
  };
1202
1234
  }
1203
1235
 
@@ -1335,6 +1367,7 @@ async function downloadOne(
1335
1367
 
1336
1368
  counters.filesDownloaded++;
1337
1369
  counters.bytesDownloaded += size;
1370
+ counters.changedPaths.push(remoteFile.key);
1338
1371
  } catch (err) {
1339
1372
  if (isAccessDenied(err)) {
1340
1373
  counters.filesSkipped++;
@@ -1474,6 +1507,7 @@ function executeJournalTombstoneDeletes(
1474
1507
  }
1475
1508
  removeEntry(run.journal, key);
1476
1509
  counters.filesTombstoned++;
1510
+ counters.changedPaths.push(key);
1477
1511
  run.emit({
1478
1512
  type: "progress",
1479
1513
  path: key,
@@ -1532,6 +1566,7 @@ function finalizePullRun(
1532
1566
  filesOutOfScope: counters.filesOutOfScope,
1533
1567
  scopeOrphansRemoved: scopeRun.scopeOrphansRemoved,
1534
1568
  scopeOrphansBlocked: scopeRun.shrinkResult.dirtyTombstoned,
1569
+ changedPaths: [...counters.changedPaths, ...scopeRun.changedPaths],
1535
1570
  };
1536
1571
  }
1537
1572
 
@@ -2462,5 +2497,10 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
2462
2497
  if (event.count > event.samplePaths.length) {
2463
2498
  console.warn(` ... and ${event.count - event.samplePaths.length} more`);
2464
2499
  }
2500
+ } else if (event.type === "skip-size-limit") {
2501
+ const mb = (event.bytes / (1024 * 1024)).toFixed(1);
2502
+ console.warn(
2503
+ ` ! ${event.path} skipped — ${mb} MB exceeds the 50 MB sync size limit`,
2504
+ );
2465
2505
  }
2466
2506
  }
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { isTransientNetworkError, TRANSIENT_NETWORK_EXIT } from "./net-errors.js";
3
+
4
+ describe("isTransientNetworkError", () => {
5
+ it("treats undici's canonical `fetch failed` TypeError as transient", () => {
6
+ // This is the exact shape the runner's discovery step sees in production
7
+ // (HQ-SYNC-1W): `{"type":"error","message":"fetch failed","path":"(discovery)"}`.
8
+ expect(isTransientNetworkError(new TypeError("fetch failed"))).toBe(true);
9
+ });
10
+
11
+ it("treats known transport `.code`s as transient", () => {
12
+ for (const code of ["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ETIMEDOUT"]) {
13
+ const err = Object.assign(new Error("boom"), { code });
14
+ expect(isTransientNetworkError(err)).toBe(true);
15
+ }
16
+ });
17
+
18
+ it("walks the .cause chain (fetch failed wrapping a DNS error)", () => {
19
+ const cause = Object.assign(new Error("getaddrinfo ENOTFOUND api"), {
20
+ code: "ENOTFOUND",
21
+ });
22
+ const err = Object.assign(new TypeError("fetch failed"), { cause });
23
+ expect(isTransientNetworkError(err)).toBe(true);
24
+ });
25
+
26
+ it("walks AggregateError.errors (happy-eyeballs)", () => {
27
+ const agg = Object.assign(new AggregateError([
28
+ Object.assign(new Error("v4"), { code: "ECONNREFUSED" }),
29
+ Object.assign(new Error("v6"), { code: "EHOSTUNREACH" }),
30
+ ], "all attempts failed"), {});
31
+ expect(isTransientNetworkError(agg)).toBe(true);
32
+ });
33
+
34
+ it("is conservative: an arbitrary error is NOT transient", () => {
35
+ // A bare message like "network down" (no code, not the canonical undici
36
+ // message) must not be misclassified — only real transport shapes qualify.
37
+ expect(isTransientNetworkError(new Error("network down"))).toBe(false);
38
+ expect(isTransientNetworkError(new Error("boom"))).toBe(false);
39
+ expect(isTransientNetworkError("fetch failed")).toBe(false);
40
+ expect(isTransientNetworkError(undefined)).toBe(false);
41
+ });
42
+
43
+ it("does not infinite-loop on a self-referential cause", () => {
44
+ const err = new Error("weird") as Error & { cause?: unknown };
45
+ err.cause = err;
46
+ expect(isTransientNetworkError(err)).toBe(false);
47
+ });
48
+
49
+ it("exposes a distinct exit code off the runner's other codes", () => {
50
+ expect(TRANSIENT_NETWORK_EXIT).toBe(75);
51
+ expect([0, 1, 2, 17]).not.toContain(TRANSIENT_NETWORK_EXIT);
52
+ });
53
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Transient network-failure classification for the sync runner.
3
+ *
4
+ * The auto-sync watcher runs an unattended poll loop. At the top of every pass
5
+ * it calls `GET /membership/me` to resolve which companies to sync. When the
6
+ * machine is briefly offline (wifi drop, waking from sleep, a DNS blip, the
7
+ * vault API momentarily unreachable) that call fails at the transport layer —
8
+ * Node's `fetch` throws `TypeError: fetch failed` with the real cause (an
9
+ * `ECONNREFUSED` / `ENOTFOUND` / `ETIMEDOUT` / … Error) on `.cause`.
10
+ *
11
+ * This is NOT a crash: the next poll (~30s later) succeeds once the network is
12
+ * back. But the runner used to `return 1` for it, the watch loop propagated
13
+ * that non-zero exit, the process exited, and the menubar supervisor reported
14
+ * "auto-sync watcher exited unexpectedly (code=Some(1))" for every blip — the
15
+ * HQ-SYNC-1W cluster. We classify these so the watch loop can stay alive and
16
+ * retry instead of surfacing a false crash, while a one-shot `hq sync` still
17
+ * exits non-zero so a human running it by hand sees the failure.
18
+ */
19
+
20
+ /**
21
+ * Distinct runner exit code for a transient network failure. The watch loop
22
+ * treats it as "stay alive, retry next tick" (never a crash); a one-shot run
23
+ * still exits non-zero so the failure is visible to a human/script. Value 75 =
24
+ * `EX_TEMPFAIL` from sysexits.h ("temporary failure; retry"). Kept off the
25
+ * codes already in use by the runner (0 ok, 1 error, 2 partial, 17 op-locked).
26
+ */
27
+ export const TRANSIENT_NETWORK_EXIT = 75;
28
+
29
+ /**
30
+ * Node/undici error `code`s that mean "the request never completed at the
31
+ * transport layer" — the machine could not reach the host. All are transient:
32
+ * a later attempt from the same machine can succeed unchanged.
33
+ */
34
+ const TRANSIENT_CODES = new Set<string>([
35
+ "ECONNREFUSED",
36
+ "ECONNRESET",
37
+ "ENOTFOUND",
38
+ "EAI_AGAIN",
39
+ "ETIMEDOUT",
40
+ "EPIPE",
41
+ "EHOSTUNREACH",
42
+ "EHOSTDOWN",
43
+ "ENETUNREACH",
44
+ "ENETDOWN",
45
+ "ECONNABORTED",
46
+ // undici's own timeout/socket failures
47
+ "UND_ERR_CONNECT_TIMEOUT",
48
+ "UND_ERR_HEADERS_TIMEOUT",
49
+ "UND_ERR_BODY_TIMEOUT",
50
+ "UND_ERR_SOCKET",
51
+ ]);
52
+
53
+ /**
54
+ * True when `err` is (or wraps) a transient network-transport failure. Walks the
55
+ * `.cause` chain and `AggregateError.errors` (undici's happy-eyeballs raises the
56
+ * latter), matching either undici's canonical `TypeError: fetch failed` message
57
+ * or a known transient `.code`. Conservative by design: an arbitrary
58
+ * `new Error("network down")` with no code and a non-canonical message is NOT
59
+ * treated as transient, so only real transport-failure shapes qualify.
60
+ */
61
+ export function isTransientNetworkError(err: unknown, depth = 0): boolean {
62
+ if (depth > 8 || !(err instanceof Error)) return false;
63
+
64
+ // undici's transport failure surfaces as exactly this message.
65
+ if (err.message === "fetch failed") return true;
66
+
67
+ const code = (err as { code?: unknown }).code;
68
+ if (typeof code === "string" && TRANSIENT_CODES.has(code)) return true;
69
+
70
+ // AggregateError (e.g. happy-eyeballs) stashes the per-attempt errors here.
71
+ const aggregated = (err as { errors?: unknown }).errors;
72
+ if (Array.isArray(aggregated)) {
73
+ for (const inner of aggregated) {
74
+ if (isTransientNetworkError(inner, depth + 1)) return true;
75
+ }
76
+ }
77
+
78
+ const cause = (err as { cause?: unknown }).cause;
79
+ if (cause !== undefined && cause !== err) {
80
+ return isTransientNetworkError(cause, depth + 1);
81
+ }
82
+ return false;
83
+ }
@@ -100,6 +100,56 @@ describe("reindexAfterSync", () => {
100
100
  expect(calls).toEqual([["collection", "list"], ["update"]]);
101
101
  });
102
102
 
103
+ it("skips qmd entirely when provided changed paths do not touch knowledge markdown", () => {
104
+ const { exec, calls } = fakeExec({ listStdout: "" });
105
+ const r = reindexAfterSync("/hq", {
106
+ exec,
107
+ existsSync: () => true,
108
+ changedPaths: ["companies/acme/docs/readme.txt"],
109
+ });
110
+ expect(r.qmdAvailable).toBe(false);
111
+ expect(r.updated).toBe(false);
112
+ expect(calls).toEqual([]);
113
+ });
114
+
115
+ it("debounces dirty updates with a pending marker and flushes the final update later", () => {
116
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-qmd-debounce-"));
117
+ try {
118
+ const statePath = path.join(dir, "qmd-state.json");
119
+ const { exec, calls } = fakeExec({ listStdout: "acme (qmd://acme/)\n" });
120
+
121
+ const first = reindexAfterSync("/hq", {
122
+ exec,
123
+ existsSync: () => true,
124
+ changedPaths: ["companies/acme/knowledge/note.md"],
125
+ debounceMs: 60_000,
126
+ nowMs: 100_000,
127
+ statePath,
128
+ });
129
+ expect(first.updated).toBe(false);
130
+ expect(first.pendingDirty).toBe(true);
131
+ expect(calls).toEqual([["collection", "list"]]);
132
+
133
+ const second = reindexAfterSync("/hq", {
134
+ exec,
135
+ existsSync: () => true,
136
+ changedPaths: [],
137
+ debounceMs: 60_000,
138
+ nowMs: 161_000,
139
+ statePath,
140
+ });
141
+ expect(second.updated).toBe(true);
142
+ expect(second.pendingDirty).toBe(false);
143
+ expect(calls).toEqual([
144
+ ["collection", "list"],
145
+ ["collection", "list"],
146
+ ["update"],
147
+ ]);
148
+ } finally {
149
+ fs.rmSync(dir, { recursive: true, force: true });
150
+ }
151
+ });
152
+
103
153
  it("runs embed only when embed:true", () => {
104
154
  const { exec, calls } = fakeExec({ listStdout: "" });
105
155
  const r = reindexAfterSync("/hq", {