@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.
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +127 -2
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +88 -0
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/personal-vault-exclusions.d.ts +1 -6
- package/dist/personal-vault-exclusions.d.ts.map +1 -1
- package/dist/personal-vault-exclusions.js +34 -6
- package/dist/personal-vault-exclusions.js.map +1 -1
- package/dist/personal-vault-exclusions.test.js +22 -0
- package/dist/personal-vault-exclusions.test.js.map +1 -1
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +9 -1
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +27 -1
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/s3.d.ts +1 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +65 -0
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +42 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/vault-client.d.ts +8 -1
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +29 -1
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +10 -1
- package/dist/vault-client.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/reindex.test.ts +116 -0
- package/src/cli/reindex.ts +125 -2
- package/src/personal-vault-exclusions.test.ts +24 -0
- package/src/personal-vault-exclusions.ts +35 -5
- package/src/personal-vault.test.ts +30 -0
- package/src/personal-vault.ts +9 -1
- package/src/s3.test.ts +53 -0
- package/src/s3.ts +68 -0
- package/src/vault-client.test.ts +12 -1
- 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
|
}
|
package/src/vault-client.test.ts
CHANGED
|
@@ -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, {
|
package/src/vault-client.ts
CHANGED
|
@@ -462,7 +462,14 @@ export interface TelemetryEventsBatch {
|
|
|
462
462
|
events: RawTelemetryEventInput[];
|
|
463
463
|
}
|
|
464
464
|
|
|
465
|
-
/**
|
|
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,
|
|
1394
|
+
return this.post("/v1/telemetry/events", batch, telemetryEventsIngestResultSchema, {
|
|
1353
1395
|
timeoutMs: options.timeoutMs ?? 1500,
|
|
1354
1396
|
maxRetries: 0,
|
|
1355
1397
|
});
|