@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.
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +15 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-rollup.d.ts +7 -0
- package/dist/bin/sync-runner-rollup.d.ts.map +1 -1
- package/dist/bin/sync-runner-rollup.js +15 -0
- package/dist/bin/sync-runner-rollup.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +10 -1
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +7 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +36 -7
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +142 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.js +15 -2
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +39 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +22 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +14 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/lib/net-errors.d.ts +36 -0
- package/dist/lib/net-errors.d.ts.map +1 -0
- package/dist/lib/net-errors.js +81 -0
- package/dist/lib/net-errors.js.map +1 -0
- package/dist/lib/net-errors.test.d.ts +2 -0
- package/dist/lib/net-errors.test.d.ts.map +1 -0
- package/dist/lib/net-errors.test.js +47 -0
- package/dist/lib/net-errors.test.js.map +1 -0
- package/dist/qmd-reindex.d.ts +14 -0
- package/dist/qmd-reindex.d.ts.map +1 -1
- package/dist/qmd-reindex.js +93 -19
- package/dist/qmd-reindex.js.map +1 -1
- package/dist/qmd-reindex.test.js +47 -0
- package/dist/qmd-reindex.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +21 -0
- package/src/bin/sync-runner-rollup.ts +23 -0
- package/src/bin/sync-runner-watch-loop.ts +11 -1
- package/src/bin/sync-runner.test.ts +167 -0
- package/src/bin/sync-runner.ts +49 -9
- package/src/cli/share.test.ts +46 -0
- package/src/cli/share.ts +14 -2
- package/src/cli/sync.ts +40 -0
- package/src/lib/net-errors.test.ts +53 -0
- package/src/lib/net-errors.ts +83 -0
- package/src/qmd-reindex.test.ts +50 -0
- package/src/qmd-reindex.ts +124 -18
package/src/bin/sync-runner.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1254
|
-
|
|
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",
|
package/src/cli/share.test.ts
CHANGED
|
@@ -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: "
|
|
1151
|
+
type: "skip-size-limit",
|
|
1140
1152
|
path: item.relativePath,
|
|
1141
|
-
|
|
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
|
+
}
|
package/src/qmd-reindex.test.ts
CHANGED
|
@@ -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", {
|