@indigoai-us/hq-cloud 6.11.6 → 6.11.8
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.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +1 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +1 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +23 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +83 -15
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +198 -7
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +16 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +1 -62
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/tombstones.d.ts +43 -0
- package/dist/cli/tombstones.d.ts.map +1 -0
- package/dist/cli/tombstones.js +78 -0
- package/dist/cli/tombstones.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/personal-vault.d.ts +36 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +89 -1
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +143 -1
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +22 -1
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +29 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +1 -0
- package/src/bin/sync-runner.ts +1 -0
- package/src/cli/share.test.ts +228 -7
- package/src/cli/share.ts +120 -24
- package/src/cli/sync.ts +21 -88
- package/src/cli/tombstones.ts +106 -0
- package/src/index.ts +2 -0
- package/src/personal-vault.test.ts +175 -0
- package/src/personal-vault.ts +86 -1
- package/src/watcher.test.ts +41 -0
- package/src/watcher.ts +24 -1
package/dist/cli/share.test.js
CHANGED
|
@@ -282,6 +282,148 @@ describe("share", () => {
|
|
|
282
282
|
expect(result.filesUploaded).toBe(1);
|
|
283
283
|
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "changed.md", undefined, expect.anything());
|
|
284
284
|
});
|
|
285
|
+
// ── Push-side FILE_TOMBSTONE consult (delete-resync, 3B) ────────────────────
|
|
286
|
+
// An authoritative delete (`hq files delete`) writes a FILE_TOMBSTONE and
|
|
287
|
+
// removes the S3 object. Without a push-side consult, a behind peer that still
|
|
288
|
+
// holds the deleted file RE-UPLOADS it on a skipUnchanged=false push (e.g.
|
|
289
|
+
// `hq cloud share <path>`); the re-upload post-dates the tombstone, so the
|
|
290
|
+
// pull planner's timestamp-only re-create heuristic treats it as a genuine
|
|
291
|
+
// re-create and resurrects the key for everyone. These tests pin the fix: a
|
|
292
|
+
// stale-baseline copy is suppressed, while genuine content (locally-changed or
|
|
293
|
+
// no-journal) still uploads. Differential proof: reverting the consult in
|
|
294
|
+
// share.ts makes the first test fail (uploadFile IS called → resurrection).
|
|
295
|
+
describe("push-side tombstone consult", () => {
|
|
296
|
+
function seedJournal(key, hash, size) {
|
|
297
|
+
fs.writeFileSync(path.join(stateDir, "sync-journal.acme.json"), JSON.stringify({
|
|
298
|
+
version: "1",
|
|
299
|
+
lastSync: new Date().toISOString(),
|
|
300
|
+
files: {
|
|
301
|
+
[key]: {
|
|
302
|
+
hash,
|
|
303
|
+
size,
|
|
304
|
+
syncedAt: new Date().toISOString(),
|
|
305
|
+
direction: "down",
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
}));
|
|
309
|
+
}
|
|
310
|
+
it("suppresses re-upload of a stale-baseline copy of a tombstoned key", async () => {
|
|
311
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
312
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
313
|
+
const f = path.join(companyRoot, "docs", "shared.md");
|
|
314
|
+
fs.writeFileSync(f, "shared content");
|
|
315
|
+
const { hashFile } = await import("../journal.js");
|
|
316
|
+
// Journal hash === current file hash → this machine holds the exact
|
|
317
|
+
// deleted baseline (a behind peer that never pulled the delete).
|
|
318
|
+
seedJournal("docs/shared.md", hashFile(f), 14);
|
|
319
|
+
const events = [];
|
|
320
|
+
const result = await share({
|
|
321
|
+
paths: [path.join(companyRoot, "docs")],
|
|
322
|
+
company: "acme",
|
|
323
|
+
vaultConfig: mockConfig,
|
|
324
|
+
hqRoot: tmpDir,
|
|
325
|
+
// skipUnchanged omitted (=false): models `hq cloud share <path>`, the
|
|
326
|
+
// path where a behind peer would otherwise re-upload the stale copy.
|
|
327
|
+
fileTombstones: new Map([
|
|
328
|
+
["docs/shared.md", { deletedAt: new Date().toISOString() }],
|
|
329
|
+
]),
|
|
330
|
+
onEvent: (e) => events.push(e),
|
|
331
|
+
});
|
|
332
|
+
// The resurrection is blocked at the source: no upload for the tombstoned key.
|
|
333
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
334
|
+
expect(result.filesUploaded).toBe(0);
|
|
335
|
+
expect(result.filesSuppressedByTombstone).toBe(1);
|
|
336
|
+
expect(events.some((e) => e.type === "upload-suppressed-tombstone" && e.path === "docs/shared.md")).toBe(true);
|
|
337
|
+
});
|
|
338
|
+
it("still uploads a LOCALLY-CHANGED file even when tombstoned (genuine edit/re-create)", async () => {
|
|
339
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
340
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
341
|
+
const f = path.join(companyRoot, "docs", "shared.md");
|
|
342
|
+
fs.writeFileSync(f, "edited-since-delete");
|
|
343
|
+
// Journal hash is stale (the file was edited after the delete) → genuine
|
|
344
|
+
// new content, must NOT be suppressed.
|
|
345
|
+
seedJournal("docs/shared.md", "stale-baseline-hash", 14);
|
|
346
|
+
const result = await share({
|
|
347
|
+
paths: [path.join(companyRoot, "docs")],
|
|
348
|
+
company: "acme",
|
|
349
|
+
vaultConfig: mockConfig,
|
|
350
|
+
hqRoot: tmpDir,
|
|
351
|
+
fileTombstones: new Map([
|
|
352
|
+
["docs/shared.md", { deletedAt: new Date().toISOString() }],
|
|
353
|
+
]),
|
|
354
|
+
});
|
|
355
|
+
expect(result.filesSuppressedByTombstone).toBe(0);
|
|
356
|
+
expect(result.filesUploaded).toBe(1);
|
|
357
|
+
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), f, "docs/shared.md", undefined, expect.anything());
|
|
358
|
+
});
|
|
359
|
+
it("still uploads a tombstoned key with NO journal entry (genuine re-create)", async () => {
|
|
360
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
361
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
362
|
+
const f = path.join(companyRoot, "docs", "shared.md");
|
|
363
|
+
fs.writeFileSync(f, "freshly created");
|
|
364
|
+
// No journal entry seeded → indistinguishable from genuine new content;
|
|
365
|
+
// fail-open and upload (mirrors the pull side's `!journalEntry` branch).
|
|
366
|
+
const result = await share({
|
|
367
|
+
paths: [path.join(companyRoot, "docs")],
|
|
368
|
+
company: "acme",
|
|
369
|
+
vaultConfig: mockConfig,
|
|
370
|
+
hqRoot: tmpDir,
|
|
371
|
+
fileTombstones: new Map([
|
|
372
|
+
["docs/shared.md", { deletedAt: new Date().toISOString() }],
|
|
373
|
+
]),
|
|
374
|
+
});
|
|
375
|
+
expect(result.filesSuppressedByTombstone).toBe(0);
|
|
376
|
+
expect(result.filesUploaded).toBe(1);
|
|
377
|
+
});
|
|
378
|
+
it("auto-fetches tombstones via vaultConfig (no injection) and suppresses", async () => {
|
|
379
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
380
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
381
|
+
const f = path.join(companyRoot, "docs", "shared.md");
|
|
382
|
+
fs.writeFileSync(f, "shared content");
|
|
383
|
+
const { hashFile } = await import("../journal.js");
|
|
384
|
+
seedJournal("docs/shared.md", hashFile(f), 14);
|
|
385
|
+
// Re-stub fetch to also answer GET /v1/files/tombstones, proving share()
|
|
386
|
+
// fetches and consults tombstones on its own (the production wiring) when
|
|
387
|
+
// no map is injected.
|
|
388
|
+
const fetchMock = vi.fn().mockImplementation(async (url) => {
|
|
389
|
+
const u = String(url);
|
|
390
|
+
if (u.includes("/entity/check-slug/me")) {
|
|
391
|
+
return {
|
|
392
|
+
ok: true,
|
|
393
|
+
status: 200,
|
|
394
|
+
json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
|
|
395
|
+
text: async () => "",
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
if (u.includes("/entity/by-slug/") || /\/entity\/cmp_/.test(u)) {
|
|
399
|
+
return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
|
|
400
|
+
}
|
|
401
|
+
if (u.includes("/sts/vend")) {
|
|
402
|
+
return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
|
|
403
|
+
}
|
|
404
|
+
if (u.includes("/v1/files/tombstones")) {
|
|
405
|
+
return {
|
|
406
|
+
ok: true,
|
|
407
|
+
status: 200,
|
|
408
|
+
json: async () => ({
|
|
409
|
+
tombstones: [{ key: "docs/shared.md", deletedAt: new Date().toISOString() }],
|
|
410
|
+
}),
|
|
411
|
+
text: async () => "",
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
return { ok: false, status: 404, text: async () => "Not found" };
|
|
415
|
+
});
|
|
416
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
417
|
+
const result = await share({
|
|
418
|
+
paths: [path.join(companyRoot, "docs")],
|
|
419
|
+
company: "acme",
|
|
420
|
+
vaultConfig: mockConfig,
|
|
421
|
+
hqRoot: tmpDir,
|
|
422
|
+
});
|
|
423
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
424
|
+
expect(result.filesSuppressedByTombstone).toBe(1);
|
|
425
|
+
});
|
|
426
|
+
});
|
|
285
427
|
it("populates conflictPaths and emits a conflict event when both local and remote drifted from journal", async () => {
|
|
286
428
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
287
429
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
@@ -3431,7 +3573,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
3431
3573
|
expect(errorEvents.length).toBe(1);
|
|
3432
3574
|
expect(errorEvents[0].path).toBe("f-2.bin");
|
|
3433
3575
|
});
|
|
3434
|
-
// ──
|
|
3576
|
+
// ── Interactive conflict prompt serialization ───────────────────────
|
|
3435
3577
|
//
|
|
3436
3578
|
// The parallel pool can run multiple processUploadItem workers
|
|
3437
3579
|
// concurrently; each worker calls resolveConflict() on a conflict, and
|
|
@@ -3439,12 +3581,11 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
3439
3581
|
// prompt on process.stdin. Two prompts open at once would race for the
|
|
3440
3582
|
// same terminal input — answers would interleave nondeterministically.
|
|
3441
3583
|
//
|
|
3442
|
-
//
|
|
3443
|
-
//
|
|
3444
|
-
//
|
|
3445
|
-
//
|
|
3446
|
-
//
|
|
3447
|
-
// keeps full parallelism.
|
|
3584
|
+
// Fix: rather than force the WHOLE pool to concurrency=1 (which made an
|
|
3585
|
+
// interactive `hq sync now` crawl even with zero conflicts), the pool
|
|
3586
|
+
// keeps full concurrency and serializes ONLY the prompt via a chained
|
|
3587
|
+
// lock (`resolveConflictSerialized`). At most one prompt awaits input at
|
|
3588
|
+
// a time; this test pins that invariant.
|
|
3448
3589
|
it("serializes interactive conflict prompts (no two readline prompts open at once)", async () => {
|
|
3449
3590
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3450
3591
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
@@ -3548,6 +3689,56 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
3548
3689
|
// lower bound so the test isn't flaky on slow CI.
|
|
3549
3690
|
expect(maxConcurrent).toBeGreaterThanOrEqual(4);
|
|
3550
3691
|
});
|
|
3692
|
+
it("interactive mode (onConflict unset) still runs transfers concurrently", async () => {
|
|
3693
|
+
// REGRESSION: the 5.36.x interactive guard forced the WHOLE pool to
|
|
3694
|
+
// concurrency=1 whenever onConflict was unset — so `hq sync now` (which
|
|
3695
|
+
// omits --on-conflict) crawled through transfers one at a time even when
|
|
3696
|
+
// no conflict ever occurred. The prompt is now serialized on its own
|
|
3697
|
+
// chained lock, leaving the transfer pool at full concurrency.
|
|
3698
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3699
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3700
|
+
const FILE_COUNT = 16;
|
|
3701
|
+
for (let i = 0; i < FILE_COUNT; i++) {
|
|
3702
|
+
fs.writeFileSync(path.join(companyRoot, `f-${i.toString().padStart(2, "0")}.bin`), `c-${i}`);
|
|
3703
|
+
}
|
|
3704
|
+
const startTimes = [];
|
|
3705
|
+
const endTimes = [];
|
|
3706
|
+
vi.mocked(uploadFile).mockImplementation(async () => {
|
|
3707
|
+
startTimes.push(Date.now());
|
|
3708
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
3709
|
+
endTimes.push(Date.now());
|
|
3710
|
+
return { etag: '"upload-etag"' };
|
|
3711
|
+
});
|
|
3712
|
+
await share({
|
|
3713
|
+
paths: [companyRoot],
|
|
3714
|
+
company: "acme",
|
|
3715
|
+
vaultConfig: mockConfig,
|
|
3716
|
+
hqRoot: tmpDir,
|
|
3717
|
+
// onConflict intentionally omitted → interactive mode. No conflicts
|
|
3718
|
+
// occur, so the transfer pool must NOT serialize.
|
|
3719
|
+
});
|
|
3720
|
+
const sortedEvents = [];
|
|
3721
|
+
for (const t of startTimes)
|
|
3722
|
+
sortedEvents.push({ t, kind: "start" });
|
|
3723
|
+
for (const t of endTimes)
|
|
3724
|
+
sortedEvents.push({ t, kind: "end" });
|
|
3725
|
+
sortedEvents.sort((a, b) => a.t - b.t || (a.kind === "start" ? -1 : 1));
|
|
3726
|
+
let cur = 0;
|
|
3727
|
+
let maxConcurrent = 0;
|
|
3728
|
+
for (const ev of sortedEvents) {
|
|
3729
|
+
if (ev.kind === "start") {
|
|
3730
|
+
cur++;
|
|
3731
|
+
if (cur > maxConcurrent)
|
|
3732
|
+
maxConcurrent = cur;
|
|
3733
|
+
}
|
|
3734
|
+
else {
|
|
3735
|
+
cur--;
|
|
3736
|
+
}
|
|
3737
|
+
}
|
|
3738
|
+
// Pre-fix this was exactly 1 (forced serial). Now it tracks the full
|
|
3739
|
+
// pool; 4 is a conservative lower bound to avoid CI flakiness.
|
|
3740
|
+
expect(maxConcurrent).toBeGreaterThanOrEqual(4);
|
|
3741
|
+
});
|
|
3551
3742
|
// ── Codex P1 (5.36.x): pool drains in-flight on worker rejection ────
|
|
3552
3743
|
//
|
|
3553
3744
|
// Pre-fix: the scheduler waited on `Promise.race(inFlight)`. If any
|