@indigoai-us/hq-cloud 6.14.15 → 6.14.17

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 (39) hide show
  1. package/dist/cli/reindex.d.ts.map +1 -1
  2. package/dist/cli/reindex.js +127 -2
  3. package/dist/cli/reindex.js.map +1 -1
  4. package/dist/cli/reindex.test.js +88 -0
  5. package/dist/cli/reindex.test.js.map +1 -1
  6. package/dist/personal-vault-exclusions.d.ts +1 -6
  7. package/dist/personal-vault-exclusions.d.ts.map +1 -1
  8. package/dist/personal-vault-exclusions.js +34 -6
  9. package/dist/personal-vault-exclusions.js.map +1 -1
  10. package/dist/personal-vault-exclusions.test.js +22 -0
  11. package/dist/personal-vault-exclusions.test.js.map +1 -1
  12. package/dist/personal-vault.d.ts.map +1 -1
  13. package/dist/personal-vault.js +9 -1
  14. package/dist/personal-vault.js.map +1 -1
  15. package/dist/personal-vault.test.js +27 -1
  16. package/dist/personal-vault.test.js.map +1 -1
  17. package/dist/s3.d.ts +1 -0
  18. package/dist/s3.d.ts.map +1 -1
  19. package/dist/s3.js +65 -0
  20. package/dist/s3.js.map +1 -1
  21. package/dist/s3.test.js +42 -1
  22. package/dist/s3.test.js.map +1 -1
  23. package/dist/vault-client.d.ts +8 -1
  24. package/dist/vault-client.d.ts.map +1 -1
  25. package/dist/vault-client.js +29 -1
  26. package/dist/vault-client.js.map +1 -1
  27. package/dist/vault-client.test.js +10 -1
  28. package/dist/vault-client.test.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/cli/reindex.test.ts +116 -0
  31. package/src/cli/reindex.ts +125 -2
  32. package/src/personal-vault-exclusions.test.ts +24 -0
  33. package/src/personal-vault-exclusions.ts +35 -5
  34. package/src/personal-vault.test.ts +30 -0
  35. package/src/personal-vault.ts +9 -1
  36. package/src/s3.test.ts +53 -0
  37. package/src/s3.ts +68 -0
  38. package/src/vault-client.test.ts +12 -1
  39. package/src/vault-client.ts +44 -2
package/src/s3.test.ts CHANGED
@@ -96,6 +96,7 @@ import {
96
96
  classifyVaultKey,
97
97
  validateVaultUploadKey,
98
98
  replaceStagedPath,
99
+ sweepStaleStagedFiles,
99
100
  } from "./s3.js";
100
101
  import {
101
102
  setObjectIOFactory,
@@ -1580,3 +1581,55 @@ describe("validateVaultUploadKey — direct-S3 key poisoning guard (incident 202
1580
1581
  expect(sentCommands.length).toBeGreaterThan(0);
1581
1582
  });
1582
1583
  });
1584
+
1585
+ describe("sweepStaleStagedFiles", () => {
1586
+ let tmpRoot: string;
1587
+ const HOUR = 60 * 60 * 1000;
1588
+
1589
+ beforeEach(() => {
1590
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sweep-test-"));
1591
+ });
1592
+
1593
+ afterEach(() => {
1594
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
1595
+ });
1596
+
1597
+ function makeAged(name: string, ageMs: number): string {
1598
+ const full = path.join(tmpRoot, name);
1599
+ fs.writeFileSync(full, "x");
1600
+ const stamp = new Date(Date.now() - ageMs);
1601
+ fs.utimesSync(full, stamp, stamp);
1602
+ return full;
1603
+ }
1604
+
1605
+ it("removes stale .hq-tmp-* and .hq-backup-* files, keeps everything else", () => {
1606
+ const staleTmp = makeAged(".hq-tmp-deadbeef01", 2 * HOUR);
1607
+ const staleBackup = makeAged(".hq-backup-deadbeef02", 2 * HOUR);
1608
+ const freshTmp = makeAged(".hq-tmp-deadbeef03", 0);
1609
+ const ordinary = makeAged("notes.md", 2 * HOUR);
1610
+ // A directory matching the pattern must never be touched.
1611
+ const dirSentinel = path.join(tmpRoot, ".hq-tmp-a-directory");
1612
+ fs.mkdirSync(dirSentinel);
1613
+
1614
+ const removed = sweepStaleStagedFiles(tmpRoot);
1615
+
1616
+ expect(removed.sort()).toEqual([staleTmp, staleBackup].sort());
1617
+ expect(fs.existsSync(staleTmp)).toBe(false);
1618
+ expect(fs.existsSync(staleBackup)).toBe(false);
1619
+ expect(fs.existsSync(freshTmp)).toBe(true);
1620
+ expect(fs.existsSync(ordinary)).toBe(true);
1621
+ expect(fs.existsSync(dirSentinel)).toBe(true);
1622
+ });
1623
+
1624
+ it("returns empty for a missing directory without throwing", () => {
1625
+ expect(
1626
+ sweepStaleStagedFiles(path.join(tmpRoot, "does-not-exist")),
1627
+ ).toEqual([]);
1628
+ });
1629
+
1630
+ it("respects a custom age threshold", () => {
1631
+ const halfHourOld = makeAged(".hq-tmp-halfhour", HOUR / 2);
1632
+ expect(sweepStaleStagedFiles(tmpRoot, HOUR / 4)).toEqual([halfHourOld]);
1633
+ expect(fs.existsSync(halfHourOld)).toBe(false);
1634
+ });
1635
+ });
package/src/s3.ts CHANGED
@@ -239,8 +239,76 @@ export function encodeSymlinkBody(target: string): Buffer {
239
239
  return Buffer.from(SYMLINK_BODY_PREFIX + target, "utf-8");
240
240
  }
241
241
 
242
+ /**
243
+ * Staged-download (`.hq-tmp-*`) and replacement-backup (`.hq-backup-*`)
244
+ * sentinels are cleaned by try/finally in-process, but a hard kill
245
+ * mid-transfer (agent job `timeout -k`, OOM, systemd stop) never reaches the
246
+ * finally and nothing else removes them. On a fleet box that leaked 866M of
247
+ * orphans in two days and filled /tmp (2026-07-20 incident). Sweep stale
248
+ * sentinels in the destination dir before staging a new one there.
249
+ *
250
+ * The age threshold far exceeds any live transfer, so a concurrent download's
251
+ * fresh sentinel is never touched. Only non-directories matching the sentinel
252
+ * pattern are candidates. Per-dir memoization bounds the readdir cost to once
253
+ * per directory per process run.
254
+ */
255
+ const STAGED_SENTINEL_PATTERN = /^\.hq-(tmp|backup)-/;
256
+ const STAGED_SWEEP_MAX_AGE_MS = 60 * 60 * 1000;
257
+ const sweptStagedDirs = new Set<string>();
258
+
259
+ export function sweepStaleStagedFiles(
260
+ dir: string,
261
+ maxAgeMs: number = STAGED_SWEEP_MAX_AGE_MS,
262
+ nowMs: number = Date.now(),
263
+ ): string[] {
264
+ let entries: string[];
265
+ try {
266
+ entries = fs.readdirSync(dir);
267
+ } catch (err) {
268
+ // Destination dir may not exist yet (created later by downloadFile) —
269
+ // nothing to sweep. Anything else is unexpected but must not fail the
270
+ // download that triggered the sweep.
271
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
272
+ console.warn(`[hq-sync] staged-file sweep: cannot read ${dir}: ${err}`);
273
+ }
274
+ return [];
275
+ }
276
+ const removed: string[] = [];
277
+ for (const name of entries) {
278
+ if (!STAGED_SENTINEL_PATTERN.test(name)) continue;
279
+ const full = path.join(dir, name);
280
+ try {
281
+ const stat = fs.lstatSync(full);
282
+ if (stat.isDirectory()) continue;
283
+ if (nowMs - stat.mtimeMs < maxAgeMs) continue;
284
+ fs.unlinkSync(full);
285
+ removed.push(full);
286
+ } catch (err) {
287
+ // A concurrent process may have removed it first (ENOENT: fine), or the
288
+ // file is unremovable (EPERM/EROFS) — either way the download must
289
+ // proceed; surface non-ENOENT so the leak stays visible.
290
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
291
+ console.warn(`[hq-sync] staged-file sweep: cannot remove ${full}: ${err}`);
292
+ }
293
+ }
294
+ }
295
+ if (removed.length > 0) {
296
+ console.warn(
297
+ `[hq-sync] removed ${removed.length} stale staged file(s) in ${dir}`,
298
+ );
299
+ }
300
+ return removed;
301
+ }
302
+
303
+ function sweepStaleStagedFilesOnce(dir: string): void {
304
+ if (sweptStagedDirs.has(dir)) return;
305
+ sweptStagedDirs.add(dir);
306
+ sweepStaleStagedFiles(dir);
307
+ }
308
+
242
309
  function downloadTempPath(localPath: string): string {
243
310
  const dir = path.dirname(localPath);
311
+ sweepStaleStagedFilesOnce(dir);
244
312
  const random = crypto.randomBytes(10).toString("hex");
245
313
  return path.join(dir, `.hq-tmp-${random}`);
246
314
  }
@@ -365,7 +365,7 @@ describe("API surface", () => {
365
365
 
366
366
  it("postTelemetryEvents sends action events without personUid", async () => {
367
367
  fetchSpy.mockResolvedValueOnce(
368
- jsonResponse(200, { ok: true, written: 1, skipped: [] }),
368
+ jsonResponse(200, { contractVersion: 1, ok: true, written: 1, skipped: [] }),
369
369
  );
370
370
  const batch: TelemetryEventsBatch = {
371
371
  events: [
@@ -397,6 +397,17 @@ describe("API surface", () => {
397
397
  expect(JSON.stringify(body)).not.toContain("personUid");
398
398
  });
399
399
 
400
+ it("postTelemetryEvents normalizes the compatible pre-v1 accepted response", async () => {
401
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { accepted: 2, deduped: 1 }));
402
+ const batch: TelemetryEventsBatch = { events: [] };
403
+
404
+ await expect(client.postTelemetryEvents(batch)).resolves.toEqual({
405
+ ok: true,
406
+ written: 2,
407
+ skipped: [],
408
+ });
409
+ });
410
+
400
411
  it("updateRole sends correct payload", async () => {
401
412
  fetchSpy.mockResolvedValueOnce(
402
413
  jsonResponse(200, {
@@ -462,7 +462,14 @@ export interface TelemetryEventsBatch {
462
462
  events: RawTelemetryEventInput[];
463
463
  }
464
464
 
465
- /** Same wire shape as `UsageIngestResult`; aliased for call-site clarity. */
465
+ /**
466
+ * Normalized response shape for `POST /v1/telemetry/events`.
467
+ *
468
+ * hq-pro response contract v1 uses the established ingest fields (`ok`,
469
+ * `written`, `skipped`). The decoder also accepts the briefly deployed
470
+ * `{ accepted, deduped }` shape so clients remain compatible while the server
471
+ * rollout catches up.
472
+ */
466
473
  export type TelemetryEventsIngestResult = UsageIngestResult;
467
474
 
468
475
  // ---------------------------------------------------------------------------
@@ -649,6 +656,41 @@ const usageIngestResultSchema: VaultResponseSchema<UsageIngestResult> = z
649
656
  })
650
657
  .strip();
651
658
 
659
+ /**
660
+ * Accept the canonical v1 response and the previous raw-event aliases, then
661
+ * normalize at this boundary so telemetry remains best-effort during rollout.
662
+ */
663
+ const telemetryEventsIngestResultSchema: VaultResponseSchema<TelemetryEventsIngestResult> = z
664
+ .object({
665
+ contractVersion: z.literal(1).optional(),
666
+ ok: z.boolean().optional(),
667
+ written: z.number().nonnegative().optional(),
668
+ skipped: z
669
+ .array(
670
+ z
671
+ .object({
672
+ index: z.number(),
673
+ code: z.string(),
674
+ error: z.string(),
675
+ })
676
+ .strip(),
677
+ )
678
+ .optional(),
679
+ // Compatibility aliases emitted by hq-pro before response contract v1.
680
+ accepted: z.number().nonnegative().optional(),
681
+ deduped: z.number().nonnegative().optional(),
682
+ })
683
+ .refine(
684
+ (value) =>
685
+ value.ok !== undefined || value.written !== undefined || value.accepted !== undefined,
686
+ { message: "telemetry ingest response has no recognized result fields" },
687
+ )
688
+ .transform((value) => ({
689
+ ok: value.ok ?? true,
690
+ written: value.written ?? value.accepted ?? 0,
691
+ skipped: value.skipped ?? [],
692
+ }));
693
+
652
694
  const createInviteResponseSchema: VaultResponseSchema<CreateInviteResult> = z
653
695
  .object({
654
696
  membership: membershipSchema,
@@ -1349,7 +1391,7 @@ export class VaultClient {
1349
1391
  batch: TelemetryEventsBatch,
1350
1392
  options: { timeoutMs?: number } = {},
1351
1393
  ): Promise<TelemetryEventsIngestResult> {
1352
- return this.post("/v1/telemetry/events", batch, usageIngestResultSchema, {
1394
+ return this.post("/v1/telemetry/events", batch, telemetryEventsIngestResultSchema, {
1353
1395
  timeoutMs: options.timeoutMs ?? 1500,
1354
1396
  maxRetries: 0,
1355
1397
  });