@indigoai-us/hq-cloud 6.11.10 → 6.11.12
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 +2 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +231 -52
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +330 -11
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +16 -1
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +39 -1
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +58 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.js +229 -15
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-exec-bit-preserve.test.d.ts +2 -0
- package/dist/cli/rescue-exec-bit-preserve.test.d.ts.map +1 -0
- package/dist/cli/rescue-exec-bit-preserve.test.js +169 -0
- package/dist/cli/rescue-exec-bit-preserve.test.js.map +1 -0
- package/dist/cli/share.d.ts +2 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +100 -32
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +30 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +28 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +188 -59
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +487 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +55 -10
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.js +61 -0
- package/dist/cognito-auth.test.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +93 -6
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +59 -0
- package/dist/journal.test.js.map +1 -1
- package/dist/machine-auth.test.js +60 -2
- package/dist/machine-auth.test.js.map +1 -1
- package/dist/object-io.d.ts +37 -1
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +148 -29
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +121 -0
- package/dist/object-io.test.js.map +1 -1
- package/dist/operation-lock.d.ts +8 -8
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +99 -32
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +51 -4
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/personal-vault.d.ts +8 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +17 -3
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +34 -0
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/prefix-coalesce.d.ts +20 -9
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +124 -28
- package/dist/prefix-coalesce.js.map +1 -1
- package/dist/prefix-coalesce.test.js +57 -2
- package/dist/prefix-coalesce.test.js.map +1 -1
- package/dist/remote-pull.d.ts +6 -1
- package/dist/remote-pull.d.ts.map +1 -1
- package/dist/remote-pull.js +62 -13
- package/dist/remote-pull.js.map +1 -1
- package/dist/remote-pull.test.js +189 -0
- package/dist/remote-pull.test.js.map +1 -1
- package/dist/s3.d.ts +2 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +197 -116
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +109 -0
- package/dist/s3.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +3 -2
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +1 -1
- package/dist/scope-shrink.js.map +1 -1
- package/dist/skill-telemetry.d.ts +1 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +69 -9
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +86 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +6 -0
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +34 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +73 -0
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/metrics.d.ts +17 -1
- package/dist/sync/metrics.d.ts.map +1 -1
- package/dist/sync/metrics.js +32 -1
- package/dist/sync/metrics.js.map +1 -1
- package/dist/sync/metrics.test.js +74 -1
- package/dist/sync/metrics.test.js.map +1 -1
- package/dist/sync/pull-scope.d.ts.map +1 -1
- package/dist/sync/pull-scope.js +15 -7
- package/dist/sync/pull-scope.js.map +1 -1
- package/dist/sync/push-receiver.d.ts +6 -5
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +13 -15
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +36 -1
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/telemetry.d.ts +1 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +59 -6
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +74 -0
- package/dist/telemetry.test.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/watcher.d.ts +36 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +152 -30
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +103 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +396 -11
- package/src/bin/sync-runner.ts +254 -52
- package/src/cli/reindex.test.ts +47 -1
- package/src/cli/reindex.ts +17 -1
- package/src/cli/rescue-classify-ordering.test.ts +61 -0
- package/src/cli/rescue-core.ts +261 -15
- package/src/cli/rescue-exec-bit-preserve.test.ts +187 -0
- package/src/cli/share.test.ts +38 -0
- package/src/cli/share.ts +103 -34
- package/src/cli/sync.test.ts +594 -1
- package/src/cli/sync.ts +229 -65
- package/src/cognito-auth.test.ts +77 -0
- package/src/cognito-auth.ts +73 -11
- package/src/index.ts +8 -0
- package/src/journal.test.ts +72 -0
- package/src/journal.ts +95 -8
- package/src/machine-auth.test.ts +64 -2
- package/src/object-io.test.ts +142 -0
- package/src/object-io.ts +182 -30
- package/src/operation-lock.test.ts +63 -4
- package/src/operation-lock.ts +99 -31
- package/src/personal-vault.test.ts +42 -0
- package/src/personal-vault.ts +18 -3
- package/src/prefix-coalesce.test.ts +71 -1
- package/src/prefix-coalesce.ts +155 -30
- package/src/remote-pull.test.ts +205 -0
- package/src/remote-pull.ts +77 -14
- package/src/s3.test.ts +126 -0
- package/src/s3.ts +237 -122
- package/src/scope-shrink.ts +6 -3
- package/src/skill-telemetry.test.ts +109 -0
- package/src/skill-telemetry.ts +82 -14
- package/src/sync/event-sync.test.ts +75 -0
- package/src/sync/event-sync.ts +54 -1
- package/src/sync/metrics.test.ts +81 -0
- package/src/sync/metrics.ts +59 -4
- package/src/sync/pull-scope.ts +23 -7
- package/src/sync/push-receiver.test.ts +38 -1
- package/src/sync/push-receiver.ts +15 -18
- package/src/telemetry.test.ts +85 -0
- package/src/telemetry.ts +69 -6
- package/src/types.ts +8 -0
- package/src/watcher.test.ts +117 -0
- package/src/watcher.ts +209 -33
package/dist/cli/sync.test.js
CHANGED
|
@@ -6,6 +6,7 @@ import * as fs from "fs";
|
|
|
6
6
|
import * as path from "path";
|
|
7
7
|
import * as os from "os";
|
|
8
8
|
import { clearContextCache } from "../context.js";
|
|
9
|
+
import { lockPathFor } from "../operation-lock.js";
|
|
9
10
|
// Mock s3 module at the top level
|
|
10
11
|
vi.mock("../s3.js", async () => {
|
|
11
12
|
const { vi: innerVi } = await import("vitest");
|
|
@@ -36,7 +37,7 @@ vi.mock("../s3.js", async () => {
|
|
|
36
37
|
vi.mock("./reindex.js", () => ({
|
|
37
38
|
reindex: vi.fn(() => ({ status: 0 })),
|
|
38
39
|
}));
|
|
39
|
-
import { sync } from "./sync.js";
|
|
40
|
+
import { sync, reportNewFilesToNotify } from "./sync.js";
|
|
40
41
|
import * as s3Module from "../s3.js";
|
|
41
42
|
import { reindex } from "./reindex.js";
|
|
42
43
|
const mockConfig = {
|
|
@@ -126,6 +127,25 @@ describe("sync", () => {
|
|
|
126
127
|
// skipLock: the surrounding sync run already holds the per-root lock.
|
|
127
128
|
expect(reindex).toHaveBeenCalledWith({ repoRoot: tmpDir, skipLock: true });
|
|
128
129
|
});
|
|
130
|
+
it("F15: public sync entrypoint refuses an already-held operation lock", async () => {
|
|
131
|
+
process.env.HQ_OP_LOCK_TIMEOUT = "0";
|
|
132
|
+
const lockPath = lockPathFor(tmpDir);
|
|
133
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
134
|
+
fs.writeFileSync(lockPath, JSON.stringify({
|
|
135
|
+
pid: 1,
|
|
136
|
+
command: "rescue",
|
|
137
|
+
startedAt: new Date().toISOString(),
|
|
138
|
+
hqRoot: path.resolve(tmpDir),
|
|
139
|
+
}));
|
|
140
|
+
try {
|
|
141
|
+
await expect(sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir })).rejects.toThrow(/another HQ operation is already running/);
|
|
142
|
+
expect(s3Module.listRemoteFiles).not.toHaveBeenCalled();
|
|
143
|
+
expect(reindex).not.toHaveBeenCalled();
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
delete process.env.HQ_OP_LOCK_TIMEOUT;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
129
149
|
it("skips reindex when skipReindex is set", async () => {
|
|
130
150
|
const result = await sync({
|
|
131
151
|
company: "acme",
|
|
@@ -432,6 +452,167 @@ describe("sync", () => {
|
|
|
432
452
|
// Local replaced with the (mock) remote content.
|
|
433
453
|
expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
|
|
434
454
|
});
|
|
455
|
+
it("RF-F02EXEC: refuses a download whose parent symlink appears after planning", async () => {
|
|
456
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
457
|
+
const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-escape-"));
|
|
458
|
+
const previousConcurrency = process.env.HQ_SYNC_TRANSFER_CONCURRENCY;
|
|
459
|
+
const defaultDownload = vi.mocked(s3Module.downloadFile).getMockImplementation();
|
|
460
|
+
process.env.HQ_SYNC_TRANSFER_CONCURRENCY = "1";
|
|
461
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
462
|
+
{ key: "docs/setup.md", size: 5, lastModified: new Date(), etag: '"setup"' },
|
|
463
|
+
{ key: "trap/secret.md", size: 6, lastModified: new Date(), etag: '"secret"' },
|
|
464
|
+
]);
|
|
465
|
+
vi.mocked(s3Module.downloadFile).mockImplementation(async (_ctx, key, localPath) => {
|
|
466
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
467
|
+
if (key === "docs/setup.md") {
|
|
468
|
+
fs.writeFileSync(localPath, "setup");
|
|
469
|
+
fs.symlinkSync(outsideRoot, path.join(companyRoot, "trap"), "dir");
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
fs.writeFileSync(localPath, "escaped");
|
|
473
|
+
}
|
|
474
|
+
return { metadata: {} };
|
|
475
|
+
});
|
|
476
|
+
const events = [];
|
|
477
|
+
try {
|
|
478
|
+
const result = await sync({
|
|
479
|
+
company: "acme",
|
|
480
|
+
vaultConfig: mockConfig,
|
|
481
|
+
hqRoot: tmpDir,
|
|
482
|
+
onEvent: (e) => events.push(e),
|
|
483
|
+
});
|
|
484
|
+
expect(result.filesDownloaded).toBe(1);
|
|
485
|
+
expect(fs.readFileSync(path.join(companyRoot, "docs", "setup.md"), "utf-8")).toBe("setup");
|
|
486
|
+
expect(fs.existsSync(path.join(outsideRoot, "secret.md"))).toBe(false);
|
|
487
|
+
expect(events.some((e) => e.type === "error" &&
|
|
488
|
+
e.path === "trap/secret.md" &&
|
|
489
|
+
e.message?.includes("escaped the sync root"))).toBe(true);
|
|
490
|
+
}
|
|
491
|
+
finally {
|
|
492
|
+
if (defaultDownload)
|
|
493
|
+
vi.mocked(s3Module.downloadFile).mockImplementation(defaultDownload);
|
|
494
|
+
if (previousConcurrency === undefined) {
|
|
495
|
+
delete process.env.HQ_SYNC_TRANSFER_CONCURRENCY;
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
process.env.HQ_SYNC_TRANSFER_CONCURRENCY = previousConcurrency;
|
|
499
|
+
}
|
|
500
|
+
fs.rmSync(outsideRoot, { recursive: true, force: true });
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
it("RF-F02EXEC-conflict", async () => {
|
|
504
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
505
|
+
const companyDocs = path.join(companyRoot, "docs");
|
|
506
|
+
const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-conflict-escape-"));
|
|
507
|
+
const localPath = path.join(companyDocs, "handoff.md");
|
|
508
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
509
|
+
fs.writeFileSync(localPath, "local version");
|
|
510
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
511
|
+
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"new-etag"' },
|
|
512
|
+
]);
|
|
513
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
514
|
+
version: "1",
|
|
515
|
+
lastSync: new Date().toISOString(),
|
|
516
|
+
files: {
|
|
517
|
+
"docs/handoff.md": {
|
|
518
|
+
hash: "stale-hash",
|
|
519
|
+
size: 20,
|
|
520
|
+
remoteEtag: "old-etag",
|
|
521
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
522
|
+
direction: "down",
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
}));
|
|
526
|
+
const events = [];
|
|
527
|
+
let swappedParent = false;
|
|
528
|
+
try {
|
|
529
|
+
const result = await sync({
|
|
530
|
+
company: "acme",
|
|
531
|
+
onConflict: "keep",
|
|
532
|
+
vaultConfig: mockConfig,
|
|
533
|
+
hqRoot: tmpDir,
|
|
534
|
+
onEvent: (e) => {
|
|
535
|
+
events.push(e);
|
|
536
|
+
if (e.type === "plan" && !swappedParent) {
|
|
537
|
+
swappedParent = true;
|
|
538
|
+
fs.rmSync(companyDocs, { recursive: true, force: true });
|
|
539
|
+
fs.symlinkSync(outsideRoot, companyDocs, "dir");
|
|
540
|
+
}
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
expect(swappedParent).toBe(true);
|
|
544
|
+
expect(result.conflicts).toBe(0);
|
|
545
|
+
expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
|
|
546
|
+
expect(s3Module.downloadFile).not.toHaveBeenCalled();
|
|
547
|
+
expect(fs.readdirSync(outsideRoot)).toEqual([]);
|
|
548
|
+
expect(events.some((e) => e.type === "error" &&
|
|
549
|
+
e.path === "docs/handoff.md" &&
|
|
550
|
+
e.message?.includes("escaped the sync root"))).toBe(true);
|
|
551
|
+
}
|
|
552
|
+
finally {
|
|
553
|
+
fs.rmSync(outsideRoot, { recursive: true, force: true });
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
it("RF-F33: FILE_TOMBSTONE planned against absence does not delete a new untracked file", async () => {
|
|
557
|
+
const untrackedKey = "docs/untracked.md";
|
|
558
|
+
const trackedKey = "docs/tracked.md";
|
|
559
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
560
|
+
const untrackedPath = path.join(companyRoot, untrackedKey);
|
|
561
|
+
const trackedPath = path.join(companyRoot, trackedKey);
|
|
562
|
+
fs.mkdirSync(path.dirname(trackedPath), { recursive: true });
|
|
563
|
+
fs.writeFileSync(trackedPath, "tracked baseline");
|
|
564
|
+
const { hashFile } = await import("../journal.js");
|
|
565
|
+
setupFetchMock({
|
|
566
|
+
tombstones: [
|
|
567
|
+
{ key: untrackedKey, deletedAt: "2026-06-20T00:00:00.000Z" },
|
|
568
|
+
{ key: trackedKey, deletedAt: "2026-06-20T00:00:00.000Z" },
|
|
569
|
+
],
|
|
570
|
+
});
|
|
571
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
572
|
+
{
|
|
573
|
+
key: untrackedKey,
|
|
574
|
+
size: 10,
|
|
575
|
+
lastModified: new Date("2026-06-19T00:00:00.000Z"),
|
|
576
|
+
etag: '"untracked"',
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
key: trackedKey,
|
|
580
|
+
size: 16,
|
|
581
|
+
lastModified: new Date("2026-06-19T00:00:00.000Z"),
|
|
582
|
+
etag: '"tracked"',
|
|
583
|
+
},
|
|
584
|
+
]);
|
|
585
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
586
|
+
version: "2",
|
|
587
|
+
lastSync: "2026-06-19T00:00:00.000Z",
|
|
588
|
+
files: {
|
|
589
|
+
[trackedKey]: {
|
|
590
|
+
hash: hashFile(trackedPath),
|
|
591
|
+
size: Buffer.byteLength("tracked baseline"),
|
|
592
|
+
syncedAt: "2026-06-19T00:00:00.000Z",
|
|
593
|
+
direction: "down",
|
|
594
|
+
remoteEtag: "tracked",
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
pulls: [],
|
|
598
|
+
}));
|
|
599
|
+
await sync({
|
|
600
|
+
company: "acme",
|
|
601
|
+
vaultConfig: mockConfig,
|
|
602
|
+
hqRoot: tmpDir,
|
|
603
|
+
onEvent: (e) => {
|
|
604
|
+
if (e.type === "plan") {
|
|
605
|
+
fs.mkdirSync(path.dirname(untrackedPath), { recursive: true });
|
|
606
|
+
fs.writeFileSync(untrackedPath, "brand new local work");
|
|
607
|
+
}
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
expect(fs.readFileSync(untrackedPath, "utf-8")).toBe("brand new local work");
|
|
611
|
+
expect(fs.existsSync(trackedPath)).toBe(false);
|
|
612
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
613
|
+
expect(journal.files[untrackedKey]).toBeUndefined();
|
|
614
|
+
expect(journal.files[trackedKey]).toBeUndefined();
|
|
615
|
+
});
|
|
435
616
|
it("aborts on --on-conflict abort", async () => {
|
|
436
617
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
437
618
|
fs.mkdirSync(companyDocs, { recursive: true });
|
|
@@ -505,6 +686,131 @@ describe("sync", () => {
|
|
|
505
686
|
expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
|
|
506
687
|
expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "readme.md"))).toBe(false);
|
|
507
688
|
});
|
|
689
|
+
it("personalMode: downloads + journals companies/manifest.yaml (carve-out round-trips) while still skipping other companies/* keys", async () => {
|
|
690
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
691
|
+
{ key: "companies/foo/bar.md", size: 50, lastModified: new Date(), etag: '"xyz789"' },
|
|
692
|
+
{ key: "companies/manifest.yaml", size: 40, lastModified: new Date(), etag: '"man111"' },
|
|
693
|
+
]);
|
|
694
|
+
const result = await sync({
|
|
695
|
+
company: "acme",
|
|
696
|
+
vaultConfig: mockConfig,
|
|
697
|
+
hqRoot: tmpDir,
|
|
698
|
+
personalMode: true,
|
|
699
|
+
});
|
|
700
|
+
// The manifest is the lone companies/* exemption: it downloads; other
|
|
701
|
+
// companies/* keys are still dropped.
|
|
702
|
+
expect(result.filesSkipped).toBe(1);
|
|
703
|
+
expect(result.filesDownloaded).toBe(1);
|
|
704
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "manifest.yaml"))).toBe(true);
|
|
705
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "bar.md"))).toBe(false);
|
|
706
|
+
// The whole point: it now gets a journal baseline, so the push side stops
|
|
707
|
+
// re-firing a transient conflict every sync (the bug this fix closes).
|
|
708
|
+
const journaledManifest = fs
|
|
709
|
+
.readdirSync(stateDir)
|
|
710
|
+
.filter((f) => f.startsWith("sync-journal."))
|
|
711
|
+
.some((f) => {
|
|
712
|
+
const j = JSON.parse(fs.readFileSync(path.join(stateDir, f), "utf8"));
|
|
713
|
+
return j.files?.["companies/manifest.yaml"] != null;
|
|
714
|
+
});
|
|
715
|
+
expect(journaledManifest).toBe(true);
|
|
716
|
+
});
|
|
717
|
+
it("personalMode pull lands the session-continuity pointer + active thread file under <hqRoot>/workspace/threads/ so a handoff resumes on machine B (DEV-1778)", async () => {
|
|
718
|
+
// End-to-end download leg of the cross-machine handoff. Machine A pushed
|
|
719
|
+
// workspace/threads/handoff.json + the thread file it points to into the
|
|
720
|
+
// personal bucket; machine B pulls and both must land hq-root-relative
|
|
721
|
+
// (NOT under companies/<slug>/) with the pointer still resolving to the
|
|
722
|
+
// thread file that also landed — that is what lets /startwork resume.
|
|
723
|
+
const handoffKey = "workspace/threads/handoff.json";
|
|
724
|
+
const threadKey = "workspace/threads/T-20260619-1200-resume-me.json";
|
|
725
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
726
|
+
{ key: handoffKey, size: 80, lastModified: new Date(), etag: '"h1"' },
|
|
727
|
+
{ key: threadKey, size: 40, lastModified: new Date(), etag: '"t1"' },
|
|
728
|
+
]);
|
|
729
|
+
// Materialize realistic bytes per key (the default mock writes a fixed
|
|
730
|
+
// string, but the pointer must be valid JSON referencing the thread).
|
|
731
|
+
const origDownload = vi.mocked(s3Module.downloadFile).getMockImplementation();
|
|
732
|
+
vi.mocked(s3Module.downloadFile).mockImplementation(async (_ctx, key, localPath) => {
|
|
733
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
734
|
+
const body = key.endsWith("handoff.json")
|
|
735
|
+
? JSON.stringify({ thread_path: threadKey, message: "from machine A" })
|
|
736
|
+
: JSON.stringify({ conversation_summary: "pick up here" });
|
|
737
|
+
fs.writeFileSync(localPath, body);
|
|
738
|
+
return { metadata: {} };
|
|
739
|
+
});
|
|
740
|
+
try {
|
|
741
|
+
const result = await sync({
|
|
742
|
+
company: "acme",
|
|
743
|
+
vaultConfig: mockConfig,
|
|
744
|
+
hqRoot: tmpDir,
|
|
745
|
+
personalMode: true,
|
|
746
|
+
});
|
|
747
|
+
expect(result.filesDownloaded).toBe(2);
|
|
748
|
+
const handoffLocal = path.join(tmpDir, "workspace", "threads", "handoff.json");
|
|
749
|
+
const threadLocal = path.join(tmpDir, "workspace", "threads", "T-20260619-1200-resume-me.json");
|
|
750
|
+
expect(fs.existsSync(handoffLocal)).toBe(true);
|
|
751
|
+
expect(fs.existsSync(threadLocal)).toBe(true);
|
|
752
|
+
// Pointer round-trips and resolves to the thread file that also landed.
|
|
753
|
+
const pointer = JSON.parse(fs.readFileSync(handoffLocal, "utf-8"));
|
|
754
|
+
expect(pointer.thread_path).toBe(threadKey);
|
|
755
|
+
expect(fs.existsSync(path.join(tmpDir, pointer.thread_path))).toBe(true);
|
|
756
|
+
// Must NOT be misfiled under companies/<slug>/.
|
|
757
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "acme", handoffKey))).toBe(false);
|
|
758
|
+
}
|
|
759
|
+
finally {
|
|
760
|
+
if (origDownload) {
|
|
761
|
+
vi.mocked(s3Module.downloadFile).mockImplementation(origDownload);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
it("personalMode pull does NOT clobber a newer local session-continuity pointer (conflict → keep local) (DEV-1778)", async () => {
|
|
766
|
+
// Machine B did its OWN /handoff after machine A's push, so B's local
|
|
767
|
+
// handoff.json is newer than the remote. The pull must preserve B's
|
|
768
|
+
// pointer rather than overwrite it with A's stale one — the brief's
|
|
769
|
+
// "download cleanly without clobbering a newer local pointer".
|
|
770
|
+
const threadsLocal = path.join(tmpDir, "workspace", "threads");
|
|
771
|
+
fs.mkdirSync(threadsLocal, { recursive: true });
|
|
772
|
+
fs.writeFileSync(path.join(threadsLocal, "handoff.json"), JSON.stringify({
|
|
773
|
+
thread_path: "workspace/threads/T-machineB.json",
|
|
774
|
+
message: "newer local from B",
|
|
775
|
+
}));
|
|
776
|
+
// Journal records a prior synced baseline (stale hash, no remoteEtag) so
|
|
777
|
+
// the planner sees local-changed AND remote-changed → conflict. Keys are
|
|
778
|
+
// hq-root-relative in personalMode.
|
|
779
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
780
|
+
version: "1",
|
|
781
|
+
lastSync: new Date().toISOString(),
|
|
782
|
+
files: {
|
|
783
|
+
"workspace/threads/handoff.json": {
|
|
784
|
+
hash: "old-hash-from-last-sync",
|
|
785
|
+
size: 10,
|
|
786
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
787
|
+
direction: "down",
|
|
788
|
+
},
|
|
789
|
+
},
|
|
790
|
+
}));
|
|
791
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
792
|
+
{
|
|
793
|
+
key: "workspace/threads/handoff.json",
|
|
794
|
+
size: 50,
|
|
795
|
+
lastModified: new Date(),
|
|
796
|
+
etag: '"remote-from-A"',
|
|
797
|
+
},
|
|
798
|
+
]);
|
|
799
|
+
const result = await sync({
|
|
800
|
+
company: "acme",
|
|
801
|
+
onConflict: "keep",
|
|
802
|
+
vaultConfig: mockConfig,
|
|
803
|
+
hqRoot: tmpDir,
|
|
804
|
+
personalMode: true,
|
|
805
|
+
});
|
|
806
|
+
expect(result.conflicts).toBe(1);
|
|
807
|
+
expect(result.conflictPaths).toEqual(["workspace/threads/handoff.json"]);
|
|
808
|
+
expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
|
|
809
|
+
// B's newer local pointer is preserved verbatim — not clobbered by A's.
|
|
810
|
+
const kept = JSON.parse(fs.readFileSync(path.join(threadsLocal, "handoff.json"), "utf-8"));
|
|
811
|
+
expect(kept.message).toBe("newer local from B");
|
|
812
|
+
expect(kept.thread_path).toBe("workspace/threads/T-machineB.json");
|
|
813
|
+
});
|
|
508
814
|
it("personalMode + includeLocalCompanies: downloads companies/{cloud-false-slug}/... keys when slug NOT in teamSyncedSlugs", async () => {
|
|
509
815
|
// The symmetric flip for the cloud:false → personal-bucket fallback.
|
|
510
816
|
// Machine A pushed `companies/free-co/notes.md` to the personal bucket
|
|
@@ -786,6 +1092,50 @@ describe("sync", () => {
|
|
|
786
1092
|
expect(journal.files["docs/edited-locally.md"]).toBeDefined();
|
|
787
1093
|
expect(journal.files["docs/edited-locally.md"].hash).toBe(baselineHash);
|
|
788
1094
|
});
|
|
1095
|
+
it("F33: rechecks a tombstone candidate after HEAD verification and preserves a stale local edit", async () => {
|
|
1096
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1097
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
1098
|
+
const racedPath = path.join(companyRoot, "docs", "racy-delete.md");
|
|
1099
|
+
fs.writeFileSync(racedPath, "synced baseline");
|
|
1100
|
+
const crypto = await import("node:crypto");
|
|
1101
|
+
const baselineHash = crypto
|
|
1102
|
+
.createHash("sha256")
|
|
1103
|
+
.update("synced baseline")
|
|
1104
|
+
.digest("hex");
|
|
1105
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1106
|
+
version: "1",
|
|
1107
|
+
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
1108
|
+
files: {
|
|
1109
|
+
"docs/racy-delete.md": {
|
|
1110
|
+
hash: baselineHash,
|
|
1111
|
+
size: 15,
|
|
1112
|
+
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
1113
|
+
direction: "down",
|
|
1114
|
+
remoteEtag: "remote-before-delete",
|
|
1115
|
+
},
|
|
1116
|
+
},
|
|
1117
|
+
}));
|
|
1118
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
|
|
1119
|
+
let editInjected = false;
|
|
1120
|
+
vi.mocked(s3Module.headRemoteFile).mockImplementationOnce(async (_ctx, key) => {
|
|
1121
|
+
expect(key).toBe("docs/racy-delete.md");
|
|
1122
|
+
fs.writeFileSync(racedPath, "concurrent local edit");
|
|
1123
|
+
editInjected = true;
|
|
1124
|
+
return null;
|
|
1125
|
+
});
|
|
1126
|
+
const result = await sync({
|
|
1127
|
+
company: "acme",
|
|
1128
|
+
vaultConfig: mockConfig,
|
|
1129
|
+
hqRoot: tmpDir,
|
|
1130
|
+
});
|
|
1131
|
+
expect(editInjected).toBe(true);
|
|
1132
|
+
expect(result.filesTombstoned).toBe(0);
|
|
1133
|
+
expect(fs.existsSync(racedPath)).toBe(true);
|
|
1134
|
+
expect(fs.readFileSync(racedPath, "utf-8")).toBe("concurrent local edit");
|
|
1135
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1136
|
+
expect(journal.files["docs/racy-delete.md"]).toBeDefined();
|
|
1137
|
+
expect(journal.files["docs/racy-delete.md"].hash).toBe(baselineHash);
|
|
1138
|
+
});
|
|
789
1139
|
it("does NOT tombstone symlinks whose readlink target has diverged from the journal (Codex P1 round 4)", async () => {
|
|
790
1140
|
// Codex review on PR #24 round 4 caught: the round-3 local-edit
|
|
791
1141
|
// divergence guard only covered regular files (`isFile()` is false
|
|
@@ -1350,6 +1700,71 @@ describe("sync", () => {
|
|
|
1350
1700
|
expect(result.filesDownloaded).toBe(1);
|
|
1351
1701
|
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
1352
1702
|
});
|
|
1703
|
+
it("F02: rejects traversal remote keys before they can escape the company root", async () => {
|
|
1704
|
+
const escapeName = `${path.basename(tmpDir)}-escaped.md`;
|
|
1705
|
+
const traversalKey = `../../../${escapeName}`;
|
|
1706
|
+
const escapedPath = path.join(path.dirname(tmpDir), escapeName);
|
|
1707
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1708
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1709
|
+
{
|
|
1710
|
+
key: traversalKey,
|
|
1711
|
+
size: 13,
|
|
1712
|
+
lastModified: new Date(),
|
|
1713
|
+
etag: '"traversal"',
|
|
1714
|
+
},
|
|
1715
|
+
]);
|
|
1716
|
+
try {
|
|
1717
|
+
const result = await sync({
|
|
1718
|
+
company: "acme",
|
|
1719
|
+
vaultConfig: mockConfig,
|
|
1720
|
+
hqRoot: tmpDir,
|
|
1721
|
+
});
|
|
1722
|
+
expect(result.filesDownloaded).toBe(0);
|
|
1723
|
+
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
1724
|
+
expect(s3Module.downloadFile).not.toHaveBeenCalled();
|
|
1725
|
+
expect(fs.existsSync(escapedPath)).toBe(false);
|
|
1726
|
+
expect(fs.existsSync(path.join(companyRoot, traversalKey))).toBe(false);
|
|
1727
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1728
|
+
expect(journal.files[traversalKey]).toBeUndefined();
|
|
1729
|
+
}
|
|
1730
|
+
finally {
|
|
1731
|
+
fs.rmSync(escapedPath, { force: true });
|
|
1732
|
+
}
|
|
1733
|
+
});
|
|
1734
|
+
it("R-F02: rejects remote children under an in-root symlink directory", async () => {
|
|
1735
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1736
|
+
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-escape-"));
|
|
1737
|
+
const linkDir = path.join(companyRoot, "linked-out");
|
|
1738
|
+
const remoteKey = "linked-out/owned-by-remote.md";
|
|
1739
|
+
const escapedPath = path.join(outsideDir, "owned-by-remote.md");
|
|
1740
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1741
|
+
fs.symlinkSync(outsideDir, linkDir, "dir");
|
|
1742
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1743
|
+
{
|
|
1744
|
+
key: remoteKey,
|
|
1745
|
+
size: 13,
|
|
1746
|
+
lastModified: new Date(),
|
|
1747
|
+
etag: '"symlink-dir-escape"',
|
|
1748
|
+
},
|
|
1749
|
+
]);
|
|
1750
|
+
try {
|
|
1751
|
+
const result = await sync({
|
|
1752
|
+
company: "acme",
|
|
1753
|
+
vaultConfig: mockConfig,
|
|
1754
|
+
hqRoot: tmpDir,
|
|
1755
|
+
});
|
|
1756
|
+
expect(result.filesDownloaded).toBe(0);
|
|
1757
|
+
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
1758
|
+
expect(s3Module.downloadFile).not.toHaveBeenCalled();
|
|
1759
|
+
expect(fs.existsSync(escapedPath)).toBe(false);
|
|
1760
|
+
expect(fs.existsSync(path.join(companyRoot, remoteKey))).toBe(false);
|
|
1761
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1762
|
+
expect(journal.files[remoteKey]).toBeUndefined();
|
|
1763
|
+
}
|
|
1764
|
+
finally {
|
|
1765
|
+
fs.rmSync(outsideDir, { recursive: true, force: true });
|
|
1766
|
+
}
|
|
1767
|
+
});
|
|
1353
1768
|
it("overwrites local on --on-conflict overwrite", async () => {
|
|
1354
1769
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
1355
1770
|
fs.mkdirSync(companyDocs, { recursive: true });
|
|
@@ -2124,4 +2539,75 @@ describe("sync", () => {
|
|
|
2124
2539
|
expect(entry.remoteEtag).toBe(newRemoteEtagNormalized);
|
|
2125
2540
|
});
|
|
2126
2541
|
});
|
|
2542
|
+
describe("reportNewFilesToNotify chunking (server cap = 1000 files/report)", () => {
|
|
2543
|
+
// The /v1/notify/file-added endpoint rejects an oversized batch wholesale.
|
|
2544
|
+
// Without chunking, a first sync with >1000 new files reports NONE of them and
|
|
2545
|
+
// the same oversized batch re-triggers every cycle. These lock that the client
|
|
2546
|
+
// splits into chunks at or under the cap.
|
|
2547
|
+
const cfg = {
|
|
2548
|
+
apiUrl: "https://vault-api.test",
|
|
2549
|
+
authToken: "test-jwt-token",
|
|
2550
|
+
region: "us-east-1",
|
|
2551
|
+
};
|
|
2552
|
+
const mkFiles = (n) => Array.from({ length: n }, (_v, i) => ({
|
|
2553
|
+
path: `docs/file-${i}.md`,
|
|
2554
|
+
bytes: i,
|
|
2555
|
+
addedBy: null,
|
|
2556
|
+
}));
|
|
2557
|
+
const notifyBatchSizes = (fetchMock) => fetchMock.mock.calls
|
|
2558
|
+
.filter(([u]) => String(u).includes("/v1/notify/file-added"))
|
|
2559
|
+
.map(([, init]) => JSON.parse(String(init.body)).files.length);
|
|
2560
|
+
afterEach(() => {
|
|
2561
|
+
vi.unstubAllGlobals();
|
|
2562
|
+
vi.clearAllMocks();
|
|
2563
|
+
});
|
|
2564
|
+
it("sends a single request when exactly at the cap (1000 files)", async () => {
|
|
2565
|
+
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => "" });
|
|
2566
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
2567
|
+
await reportNewFilesToNotify(cfg, "cmp_X", "acme", mkFiles(1000));
|
|
2568
|
+
const sizes = notifyBatchSizes(fetchMock);
|
|
2569
|
+
expect(sizes).toEqual([1000]); // one POST, exactly at the cap
|
|
2570
|
+
});
|
|
2571
|
+
it("splits an over-cap report into batches all at or under the cap", async () => {
|
|
2572
|
+
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => "" });
|
|
2573
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
2574
|
+
await reportNewFilesToNotify(cfg, "cmp_X", "acme", mkFiles(1001));
|
|
2575
|
+
const sizes = notifyBatchSizes(fetchMock);
|
|
2576
|
+
expect(sizes).toEqual([1000, 1]); // 1001 → 1000 + 1, never one oversized POST
|
|
2577
|
+
expect(Math.max(...sizes)).toBeLessThanOrEqual(1000);
|
|
2578
|
+
expect(sizes.reduce((a, b) => a + b, 0)).toBe(1001); // every file reported
|
|
2579
|
+
});
|
|
2580
|
+
it("chunks a large report into ceil(n/1000) batches with no file dropped", async () => {
|
|
2581
|
+
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => "" });
|
|
2582
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
2583
|
+
const all = mkFiles(2500);
|
|
2584
|
+
await reportNewFilesToNotify(cfg, "cmp_X", "acme", all);
|
|
2585
|
+
const calls = fetchMock.mock.calls.filter(([u]) => String(u).includes("/v1/notify/file-added"));
|
|
2586
|
+
const sizes = calls.map(([, init]) => JSON.parse(String(init.body)).files.length);
|
|
2587
|
+
expect(sizes).toEqual([1000, 1000, 500]); // 2500 → three batches
|
|
2588
|
+
// Union of all reported paths equals the input, in order, nothing lost.
|
|
2589
|
+
const reported = calls.flatMap(([, init]) => JSON.parse(String(init.body)).files.map((f) => f.path));
|
|
2590
|
+
expect(reported).toEqual(all.map((f) => f.path));
|
|
2591
|
+
});
|
|
2592
|
+
it("a failing chunk does not abort the remaining chunks (best-effort per batch)", async () => {
|
|
2593
|
+
let call = 0;
|
|
2594
|
+
const fetchMock = vi.fn().mockImplementation(async () => {
|
|
2595
|
+
call += 1;
|
|
2596
|
+
if (call === 1)
|
|
2597
|
+
throw new Error("notify endpoint down");
|
|
2598
|
+
return { ok: true, status: 200, text: async () => "" };
|
|
2599
|
+
});
|
|
2600
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
2601
|
+
// Must not reject even though the first chunk throws.
|
|
2602
|
+
await expect(reportNewFilesToNotify(cfg, "cmp_X", "acme", mkFiles(2001))).resolves.toBeUndefined();
|
|
2603
|
+
// All three chunks were still attempted (1000 + 1000 + 1).
|
|
2604
|
+
expect(notifyBatchSizes(fetchMock)).toEqual([1000, 1000, 1]);
|
|
2605
|
+
});
|
|
2606
|
+
it("no request at all when there are no new files", async () => {
|
|
2607
|
+
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => "" });
|
|
2608
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
2609
|
+
await reportNewFilesToNotify(cfg, "cmp_X", "acme", []);
|
|
2610
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
2611
|
+
});
|
|
2612
|
+
});
|
|
2127
2613
|
//# sourceMappingURL=sync.test.js.map
|