@indigoai-us/hq-cloud 6.15.31 → 6.15.32
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/bandwidth.d.ts +128 -0
- package/dist/bandwidth.d.ts.map +1 -0
- package/dist/bandwidth.js +254 -0
- package/dist/bandwidth.js.map +1 -0
- package/dist/bandwidth.test.d.ts +2 -0
- package/dist/bandwidth.test.d.ts.map +1 -0
- package/dist/bandwidth.test.js +243 -0
- package/dist/bandwidth.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +13 -24
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.js +71 -0
- package/dist/bin/sync-runner-company.test.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +10 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +181 -16
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +16 -1
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +40 -2
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.test.js +53 -1
- package/dist/bin/sync-runner-watch-routes.test.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +15 -3
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +66 -13
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +433 -13
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/conflict.d.ts +25 -2
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +29 -4
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/share.d.ts +40 -4
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +478 -86
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +845 -150
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +147 -18
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +20 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +261 -29
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +519 -90
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal-delta-writes.test.d.ts +2 -0
- package/dist/journal-delta-writes.test.d.ts.map +1 -0
- package/dist/journal-delta-writes.test.js +140 -0
- package/dist/journal-delta-writes.test.js.map +1 -0
- package/dist/journal.d.ts +1 -0
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +155 -25
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +29 -11
- package/dist/journal.test.js.map +1 -1
- package/dist/object-io.d.ts +34 -13
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +154 -29
- package/dist/object-io.js.map +1 -1
- package/dist/operation-lock.d.ts +12 -0
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +103 -21
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +90 -0
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/outcome-telemetry.d.ts +7 -1
- package/dist/outcome-telemetry.d.ts.map +1 -1
- package/dist/outcome-telemetry.js +15 -9
- package/dist/outcome-telemetry.js.map +1 -1
- package/dist/outcome-telemetry.test.js +15 -0
- package/dist/outcome-telemetry.test.js.map +1 -1
- package/dist/outposts/client.d.ts +2 -2
- package/dist/outposts/client.d.ts.map +1 -1
- package/dist/outposts/client.js +4 -3
- package/dist/outposts/client.js.map +1 -1
- package/dist/outposts/command.d.ts.map +1 -1
- package/dist/outposts/command.js +9 -6
- package/dist/outposts/command.js.map +1 -1
- package/dist/outposts/command.test.js +59 -2
- package/dist/outposts/command.test.js.map +1 -1
- package/dist/outposts/remote-command.d.ts +8 -8
- package/dist/outposts/remote-command.d.ts.map +1 -1
- package/dist/outposts/remote-command.js +10 -8
- package/dist/outposts/remote-command.js.map +1 -1
- package/dist/prefix-coalesce.d.ts +14 -0
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +29 -0
- package/dist/prefix-coalesce.js.map +1 -1
- package/dist/prefix-coalesce.test.js +22 -1
- package/dist/prefix-coalesce.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts +6 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +14 -8
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +43 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sts-credential-refresh.test.js +87 -2
- package/dist/sts-credential-refresh.test.js.map +1 -1
- package/dist/sync/state-store.d.ts.map +1 -1
- package/dist/sync/state-store.js +20 -1
- package/dist/sync/state-store.js.map +1 -1
- package/dist/sync/state-store.test.js +64 -0
- package/dist/sync/state-store.test.js.map +1 -1
- package/dist/sync-core.d.ts.map +1 -1
- package/dist/sync-core.js +7 -2
- package/dist/sync-core.js.map +1 -1
- package/dist/sync-core.test.js +36 -0
- package/dist/sync-core.test.js.map +1 -1
- package/dist/telemetry.d.ts +30 -6
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +331 -135
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +403 -0
- package/dist/telemetry.test.js.map +1 -1
- package/dist/types.d.ts +26 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/watcher.d.ts +76 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +105 -3
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +165 -1
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/sync.test.js
CHANGED
|
@@ -5,6 +5,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
|
5
5
|
import * as fs from "fs";
|
|
6
6
|
import * as path from "path";
|
|
7
7
|
import * as os from "os";
|
|
8
|
+
import * as crypto from "crypto";
|
|
8
9
|
import { clearContextCache } from "../context.js";
|
|
9
10
|
import { lockPathFor } from "../operation-lock.js";
|
|
10
11
|
// Re-export node:fs as a mutable module so unreadable-link regressions can
|
|
@@ -13,6 +14,13 @@ vi.mock("fs", async (importOriginal) => {
|
|
|
13
14
|
const actual = await importOriginal();
|
|
14
15
|
return { ...actual };
|
|
15
16
|
});
|
|
17
|
+
// Same mutable-namespace trick as `fs` above: `skip` is an interactive-only
|
|
18
|
+
// resolution, so the only way to exercise its arm is to stand in for the
|
|
19
|
+
// prompt. Re-exporting the real module keeps every other call site genuine.
|
|
20
|
+
vi.mock("./conflict.js", async (importOriginal) => {
|
|
21
|
+
const actual = await importOriginal();
|
|
22
|
+
return { ...actual };
|
|
23
|
+
});
|
|
16
24
|
describe("sync mutation stdin boundary", () => {
|
|
17
25
|
it("accepts only the exact sidecar command and emits one discriminated upsert result", async () => {
|
|
18
26
|
expect(SYNC_MUTATION_STDIN_COMMAND).toEqual(["sync", "mutation", "--stdin-json"]);
|
|
@@ -115,10 +123,37 @@ vi.mock("./reindex.js", () => ({
|
|
|
115
123
|
import { sync, reportNewFilesToNotify, applyRealtimeDeltaPage, isSyncMutationStdinCommand, runSyncMutationStdinJson, SYNC_MUTATION_STDIN_COMMAND, win32SymlinkFlavorBroken, } from "./sync.js";
|
|
116
124
|
import { share } from "./share.js";
|
|
117
125
|
import * as s3Module from "../s3.js";
|
|
126
|
+
import * as conflictModule from "./conflict.js";
|
|
118
127
|
import { reindex } from "./reindex.js";
|
|
119
128
|
import { readSyncProgress } from "../sync-progress.js";
|
|
120
129
|
import { VaultAuthError } from "../vault-client.js";
|
|
121
|
-
import { readJournal, getEntry } from "../journal.js";
|
|
130
|
+
import { readJournal, getEntry, listJournals } from "../journal.js";
|
|
131
|
+
/**
|
|
132
|
+
* Read a journal the way production does.
|
|
133
|
+
*
|
|
134
|
+
* These tests used to `JSON.parse` `sync-journal.<slug>.json` directly, back
|
|
135
|
+
* when that file held a full copy of the journal. It is now a small locator —
|
|
136
|
+
* the authority is the v3 state store — so the assertions below go through
|
|
137
|
+
* `readJournal`, which is what the sync engine itself uses. The slug is
|
|
138
|
+
* recovered from the path so each call site keeps naming the journal it means.
|
|
139
|
+
*/
|
|
140
|
+
function journalAt(journalPath) {
|
|
141
|
+
const slug = path
|
|
142
|
+
.basename(journalPath)
|
|
143
|
+
.replace(/^sync-journal\./, "")
|
|
144
|
+
.replace(/\.json$/, "");
|
|
145
|
+
return readJournal(slug);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The single `.conflict-` sidecar in a directory. Asserting through this rather
|
|
149
|
+
* than re-globbing inline keeps "exactly one was minted" enforced everywhere a
|
|
150
|
+
* test reaches for the preserved side.
|
|
151
|
+
*/
|
|
152
|
+
function sidecarIn(dir) {
|
|
153
|
+
const found = fs.readdirSync(dir).filter((f) => f.includes(".conflict-"));
|
|
154
|
+
expect(found).toHaveLength(1);
|
|
155
|
+
return path.join(dir, found[0]);
|
|
156
|
+
}
|
|
122
157
|
const mockConfig = {
|
|
123
158
|
apiUrl: "https://vault-api.test",
|
|
124
159
|
authToken: "test-jwt-token",
|
|
@@ -466,7 +501,7 @@ describe("sync", () => {
|
|
|
466
501
|
const result = await sync({ vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
467
502
|
expect(result.filesDownloaded).toBe(2);
|
|
468
503
|
});
|
|
469
|
-
it("detects conflicts with local changes and
|
|
504
|
+
it("detects conflicts with local changes and adopts the cloud copy on --on-conflict keep", async () => {
|
|
470
505
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
471
506
|
fs.mkdirSync(companyDocs, { recursive: true });
|
|
472
507
|
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
|
|
@@ -490,39 +525,386 @@ describe("sync", () => {
|
|
|
490
525
|
});
|
|
491
526
|
expect(result.conflicts).toBe(1);
|
|
492
527
|
expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
|
|
493
|
-
|
|
494
|
-
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("
|
|
528
|
+
// `keep` adopts the cloud body at the original name…
|
|
529
|
+
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("mock file content");
|
|
530
|
+
// …and the local one survives beside it.
|
|
531
|
+
expect(fs.readFileSync(sidecarIn(companyDocs), "utf-8")).toBe("local version");
|
|
495
532
|
});
|
|
496
|
-
it("
|
|
533
|
+
it("rechecks the current local body when executing a planned conflict", async () => {
|
|
497
534
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
498
535
|
fs.mkdirSync(companyDocs, { recursive: true });
|
|
499
|
-
|
|
536
|
+
const localPath = path.join(companyDocs, "handoff.md");
|
|
537
|
+
fs.writeFileSync(localPath, "local body seen by the planner");
|
|
500
538
|
fs.writeFileSync(journalPath, JSON.stringify({
|
|
501
539
|
version: "1",
|
|
502
540
|
lastSync: new Date().toISOString(),
|
|
503
541
|
files: {
|
|
504
542
|
"docs/handoff.md": {
|
|
505
|
-
hash: "
|
|
506
|
-
size:
|
|
543
|
+
hash: "common-base-hash",
|
|
544
|
+
size: 16,
|
|
507
545
|
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
508
546
|
direction: "down",
|
|
547
|
+
remoteEtag: "common-base-etag",
|
|
509
548
|
},
|
|
510
549
|
},
|
|
511
550
|
}));
|
|
551
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
552
|
+
{
|
|
553
|
+
key: "docs/handoff.md",
|
|
554
|
+
size: "mock file content".length,
|
|
555
|
+
lastModified: new Date(),
|
|
556
|
+
etag: '"new-remote-etag"',
|
|
557
|
+
},
|
|
558
|
+
]);
|
|
559
|
+
let changedAfterPlan = false;
|
|
512
560
|
const result = await sync({
|
|
513
561
|
company: "acme",
|
|
514
562
|
onConflict: "keep",
|
|
515
563
|
vaultConfig: mockConfig,
|
|
516
564
|
hqRoot: tmpDir,
|
|
565
|
+
onEvent: (event) => {
|
|
566
|
+
if (event.type === "plan" && !changedAfterPlan) {
|
|
567
|
+
changedAfterPlan = true;
|
|
568
|
+
// The local writer converged this path with the cloud after the
|
|
569
|
+
// whole-run preview classified it, but before its action executes.
|
|
570
|
+
fs.writeFileSync(localPath, "mock file content");
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
expect(changedAfterPlan).toBe(true);
|
|
575
|
+
expect(result.conflicts).toBe(0);
|
|
576
|
+
expect(result.conflictPaths).toEqual([]);
|
|
577
|
+
expect(fs.readdirSync(companyDocs).filter((name) => name.includes(".conflict-")))
|
|
578
|
+
.toEqual([]);
|
|
579
|
+
const entry = journalAt(journalPath).files["docs/handoff.md"];
|
|
580
|
+
expect(entry).toMatchObject({
|
|
581
|
+
hash: crypto.createHash("sha256").update("mock file content").digest("hex"),
|
|
582
|
+
remoteEtag: "new-remote-etag",
|
|
583
|
+
direction: "down",
|
|
517
584
|
});
|
|
585
|
+
expect(entry.localDiverges).toBeFalsy();
|
|
586
|
+
});
|
|
587
|
+
it("conflict skip marks the entry localDiverges (journal-honesty: the held copy never matched the recorded remote etag)", async () => {
|
|
588
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
589
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
590
|
+
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
|
|
591
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
592
|
+
version: "1",
|
|
593
|
+
lastSync: new Date().toISOString(),
|
|
594
|
+
files: {
|
|
595
|
+
"docs/handoff.md": {
|
|
596
|
+
hash: "old-hash-from-last-sync",
|
|
597
|
+
size: 20,
|
|
598
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
599
|
+
direction: "down",
|
|
600
|
+
},
|
|
601
|
+
},
|
|
602
|
+
}));
|
|
603
|
+
// `keep` no longer holds, so the honesty flag now belongs to the arms that
|
|
604
|
+
// still do. `skip` is the interactive one and is exercised through the
|
|
605
|
+
// prompt stand-in.
|
|
606
|
+
const resolveSpy = vi
|
|
607
|
+
.spyOn(conflictModule, "resolveConflict")
|
|
608
|
+
.mockResolvedValue("skip");
|
|
609
|
+
let result;
|
|
610
|
+
try {
|
|
611
|
+
result = await sync({
|
|
612
|
+
company: "acme",
|
|
613
|
+
vaultConfig: mockConfig,
|
|
614
|
+
hqRoot: tmpDir,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
finally {
|
|
618
|
+
resolveSpy.mockRestore();
|
|
619
|
+
}
|
|
518
620
|
expect(result.conflicts).toBe(1);
|
|
519
|
-
const journal =
|
|
621
|
+
const journal = journalAt(journalPath);
|
|
520
622
|
// remoteEtag still recorded so the conflict can't re-fire (#137 invariant)…
|
|
521
623
|
expect(journal.files["docs/handoff.md"].remoteEtag).toBeTruthy();
|
|
522
|
-
// …but the
|
|
624
|
+
// …but the held local copy is flagged divergent so a later currency-gated
|
|
523
625
|
// delete cannot fire on a journal that lies about local==remote.
|
|
524
626
|
expect(journal.files["docs/handoff.md"].localDiverges).toBe(true);
|
|
525
627
|
});
|
|
628
|
+
// `publish-local` reaches the PULL leg too, because the strategy is set once
|
|
629
|
+
// for a bidirectional run. It has no meaning here — there is nothing to
|
|
630
|
+
// publish while deciding what to do with an incoming object — so it MUST
|
|
631
|
+
// degrade to `keep`. If it instead fell through to the download branch it
|
|
632
|
+
// would overwrite the very local body the operator elected to publish, which
|
|
633
|
+
// is precisely the data loss the strategy exists to prevent.
|
|
634
|
+
it("conflict publish-local on the pull leg keeps local and never downloads over it", async () => {
|
|
635
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
636
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
637
|
+
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "my elected local version");
|
|
638
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
639
|
+
version: "1",
|
|
640
|
+
lastSync: new Date().toISOString(),
|
|
641
|
+
files: {
|
|
642
|
+
"docs/handoff.md": {
|
|
643
|
+
hash: "old-hash-from-last-sync",
|
|
644
|
+
size: 20,
|
|
645
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
646
|
+
direction: "down",
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
}));
|
|
650
|
+
const result = await sync({
|
|
651
|
+
company: "acme",
|
|
652
|
+
onConflict: "publish-local",
|
|
653
|
+
vaultConfig: mockConfig,
|
|
654
|
+
hqRoot: tmpDir,
|
|
655
|
+
});
|
|
656
|
+
expect(result.conflicts).toBe(1);
|
|
657
|
+
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("my elected local version");
|
|
658
|
+
// Identical bookkeeping to `keep`: the etag is stamped so the conflict
|
|
659
|
+
// cannot re-fire, and the honesty flag is armed so the push leg knows this
|
|
660
|
+
// is the divergent body it may promote under an If-Match fence.
|
|
661
|
+
const journal = journalAt(journalPath);
|
|
662
|
+
expect(journal.files["docs/handoff.md"].remoteEtag).toBeTruthy();
|
|
663
|
+
expect(journal.files["docs/handoff.md"].localDiverges).toBe(true);
|
|
664
|
+
});
|
|
665
|
+
// --- `keep` semantics, revised 2026-08-21 -------------------------------
|
|
666
|
+
//
|
|
667
|
+
// `keep` now means "the CLOUD copy becomes the working copy; my local body is
|
|
668
|
+
// preserved beside it as a `.conflict-` sidecar." It used to mean the
|
|
669
|
+
// opposite — hold local, stamp the remote etag, arm `localDiverges` — and
|
|
670
|
+
// that combination was terminal: push refused the divergent local, pull saw
|
|
671
|
+
// the remote unchanged so it never replaced the entry, and every subsequent
|
|
672
|
+
// `keep` re-armed the flag. The file could converge in neither direction, and
|
|
673
|
+
// because the desktop app hardcodes `--on-conflict keep`, that was the
|
|
674
|
+
// default outcome for every real conflict.
|
|
675
|
+
//
|
|
676
|
+
// Electing the cloud body clears the divergence BY CONSTRUCTION — the entry
|
|
677
|
+
// then genuinely records what sits on disk — while the local bytes survive
|
|
678
|
+
// under the sidecar for the operator to re-apply. Nothing is destroyed; the
|
|
679
|
+
// working copy changes and the old one is renamed.
|
|
680
|
+
it("conflict keep takes the cloud body at the original path and preserves local in a `.conflict-` sidecar", async () => {
|
|
681
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
682
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
683
|
+
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
|
|
684
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
685
|
+
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"divergent"' },
|
|
686
|
+
]);
|
|
687
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
688
|
+
version: "1",
|
|
689
|
+
lastSync: new Date().toISOString(),
|
|
690
|
+
files: {
|
|
691
|
+
"docs/handoff.md": {
|
|
692
|
+
hash: "old-hash-from-last-sync",
|
|
693
|
+
size: 20,
|
|
694
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
695
|
+
direction: "down",
|
|
696
|
+
},
|
|
697
|
+
},
|
|
698
|
+
}));
|
|
699
|
+
const result = await sync({
|
|
700
|
+
company: "acme",
|
|
701
|
+
onConflict: "keep",
|
|
702
|
+
vaultConfig: mockConfig,
|
|
703
|
+
hqRoot: tmpDir,
|
|
704
|
+
});
|
|
705
|
+
expect(result.conflicts).toBe(1);
|
|
706
|
+
expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
|
|
707
|
+
// The cloud body is the working copy now.
|
|
708
|
+
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("mock file content");
|
|
709
|
+
// …and the local body is not lost — it is sitting beside it.
|
|
710
|
+
const sidecars = fs
|
|
711
|
+
.readdirSync(companyDocs)
|
|
712
|
+
.filter((f) => f.includes(".conflict-"));
|
|
713
|
+
expect(sidecars).toHaveLength(1);
|
|
714
|
+
expect(fs.readFileSync(path.join(companyDocs, sidecars[0]), "utf-8")).toBe("local version");
|
|
715
|
+
});
|
|
716
|
+
it("conflict keep clears the fetched sidecar before rename for Windows compatibility", async () => {
|
|
717
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
718
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
719
|
+
const originalPath = path.join(companyDocs, "handoff.md");
|
|
720
|
+
fs.writeFileSync(originalPath, "local version");
|
|
721
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
722
|
+
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"divergent"' },
|
|
723
|
+
]);
|
|
724
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
725
|
+
version: "1",
|
|
726
|
+
lastSync: new Date().toISOString(),
|
|
727
|
+
files: {
|
|
728
|
+
"docs/handoff.md": {
|
|
729
|
+
hash: "old-hash-from-last-sync",
|
|
730
|
+
size: 20,
|
|
731
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
732
|
+
direction: "down",
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
}));
|
|
736
|
+
// Node's Windows rename does not replace an occupied destination. Model
|
|
737
|
+
// that rule only for the local→sidecar move; unrelated atomic index and
|
|
738
|
+
// journal renames keep using the host filesystem's normal behaviour.
|
|
739
|
+
const realRenameSync = fs.renameSync;
|
|
740
|
+
const renameSpy = vi.spyOn(fs, "renameSync").mockImplementation((from, to) => {
|
|
741
|
+
if (String(from) === originalPath &&
|
|
742
|
+
String(to).includes(".conflict-") &&
|
|
743
|
+
fs.existsSync(to)) {
|
|
744
|
+
throw errnoError("EEXIST");
|
|
745
|
+
}
|
|
746
|
+
return realRenameSync(from, to);
|
|
747
|
+
});
|
|
748
|
+
try {
|
|
749
|
+
await sync({
|
|
750
|
+
company: "acme",
|
|
751
|
+
onConflict: "keep",
|
|
752
|
+
vaultConfig: mockConfig,
|
|
753
|
+
hqRoot: tmpDir,
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
finally {
|
|
757
|
+
renameSpy.mockRestore();
|
|
758
|
+
}
|
|
759
|
+
expect(fs.readFileSync(originalPath, "utf-8")).toBe("mock file content");
|
|
760
|
+
expect(fs.readFileSync(sidecarIn(companyDocs), "utf-8")).toBe("local version");
|
|
761
|
+
});
|
|
762
|
+
it("conflict keep converges the journal on the REMOTE body and leaves localDiverges unset", async () => {
|
|
763
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
764
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
765
|
+
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
|
|
766
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
767
|
+
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"divergent"' },
|
|
768
|
+
]);
|
|
769
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
770
|
+
version: "1",
|
|
771
|
+
lastSync: new Date().toISOString(),
|
|
772
|
+
files: {
|
|
773
|
+
"docs/handoff.md": {
|
|
774
|
+
hash: "old-hash-from-last-sync",
|
|
775
|
+
size: 20,
|
|
776
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
777
|
+
direction: "down",
|
|
778
|
+
},
|
|
779
|
+
},
|
|
780
|
+
}));
|
|
781
|
+
await sync({
|
|
782
|
+
company: "acme",
|
|
783
|
+
onConflict: "keep",
|
|
784
|
+
vaultConfig: mockConfig,
|
|
785
|
+
hqRoot: tmpDir,
|
|
786
|
+
});
|
|
787
|
+
const entry = journalAt(journalPath).files["docs/handoff.md"];
|
|
788
|
+
expect(entry.remoteEtag).toBeTruthy();
|
|
789
|
+
// The whole point of the change: the entry describes the body that is
|
|
790
|
+
// actually on disk, so the honesty flag has nothing to report. A set flag
|
|
791
|
+
// here would re-create the deadlock this test exists to prevent.
|
|
792
|
+
expect(entry.localDiverges).toBeFalsy();
|
|
793
|
+
});
|
|
794
|
+
it("conflict keep records the REMOTE body's hash, so the entry is pushable instead of permanently divergent", async () => {
|
|
795
|
+
// This is the deadlock in one assertion. The old `keep` wrote the LOCAL
|
|
796
|
+
// hash next to the REMOTE etag, which is a self-contradictory entry: the
|
|
797
|
+
// push leg refuses to send a body the journal calls divergent, and the pull
|
|
798
|
+
// leg sees a remote whose etag already matches and leaves it alone. Writing
|
|
799
|
+
// the remote hash is what makes the entry describable — and therefore
|
|
800
|
+
// sendable — again.
|
|
801
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
802
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
803
|
+
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
|
|
804
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
805
|
+
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"divergent"' },
|
|
806
|
+
]);
|
|
807
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
808
|
+
version: "1",
|
|
809
|
+
lastSync: new Date().toISOString(),
|
|
810
|
+
files: {
|
|
811
|
+
"docs/handoff.md": {
|
|
812
|
+
hash: "old-hash-from-last-sync",
|
|
813
|
+
size: 20,
|
|
814
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
815
|
+
direction: "down",
|
|
816
|
+
},
|
|
817
|
+
},
|
|
818
|
+
}));
|
|
819
|
+
await sync({
|
|
820
|
+
company: "acme",
|
|
821
|
+
onConflict: "keep",
|
|
822
|
+
vaultConfig: mockConfig,
|
|
823
|
+
hqRoot: tmpDir,
|
|
824
|
+
});
|
|
825
|
+
// Pinned to the REMOTE body specifically, not to "whatever is on disk" —
|
|
826
|
+
// the old behaviour also left those two agreeing (it recorded the local
|
|
827
|
+
// hash and left the local file in place), so a disk-relative assertion
|
|
828
|
+
// would pass against the very bug this guards.
|
|
829
|
+
const remoteHash = crypto
|
|
830
|
+
.createHash("sha256")
|
|
831
|
+
.update("mock file content")
|
|
832
|
+
.digest("hex");
|
|
833
|
+
const entry = journalAt(journalPath).files["docs/handoff.md"];
|
|
834
|
+
expect(entry.hash).toBe(remoteHash);
|
|
835
|
+
});
|
|
836
|
+
it("conflict keep indexes the sidecar as the LOCAL body so /resolve-conflicts offers the right side", async () => {
|
|
837
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
838
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
839
|
+
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
|
|
840
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
841
|
+
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"divergent"' },
|
|
842
|
+
]);
|
|
843
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
844
|
+
version: "1",
|
|
845
|
+
lastSync: new Date().toISOString(),
|
|
846
|
+
files: {
|
|
847
|
+
"docs/handoff.md": {
|
|
848
|
+
hash: "old-hash-from-last-sync",
|
|
849
|
+
size: 20,
|
|
850
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
851
|
+
direction: "down",
|
|
852
|
+
},
|
|
853
|
+
},
|
|
854
|
+
}));
|
|
855
|
+
await sync({
|
|
856
|
+
company: "acme",
|
|
857
|
+
onConflict: "keep",
|
|
858
|
+
vaultConfig: mockConfig,
|
|
859
|
+
hqRoot: tmpDir,
|
|
860
|
+
});
|
|
861
|
+
const index = JSON.parse(fs.readFileSync(path.join(tmpDir, ".hq-conflicts", "index.json"), "utf-8"));
|
|
862
|
+
expect(index.conflicts).toHaveLength(1);
|
|
863
|
+
const entry = index.conflicts[0];
|
|
864
|
+
expect(entry.originalPath).toBe("companies/acme/docs/handoff.md");
|
|
865
|
+
expect(entry.preserved).toBe("local");
|
|
866
|
+
// The sidecar it points at holds the LOCAL body — the side the operator
|
|
867
|
+
// may want back. Reading the recorded path is the contract /resolve-conflicts
|
|
868
|
+
// relies on, so assert through it rather than re-globbing the directory.
|
|
869
|
+
expect(fs.readFileSync(path.join(tmpDir, entry.conflictPath), "utf-8")).toBe("local version");
|
|
870
|
+
});
|
|
871
|
+
it("interactive `skip` still leaves local in place — skip is not keep", async () => {
|
|
872
|
+
// `skip` means "do not touch this one, I will deal with it later". It is a
|
|
873
|
+
// separate arm from `keep` and must NOT adopt the cloud body; a strategy
|
|
874
|
+
// that says "leave it alone" may not rename the operator's file.
|
|
875
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
876
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
877
|
+
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
|
|
878
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
879
|
+
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"divergent"' },
|
|
880
|
+
]);
|
|
881
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
882
|
+
version: "1",
|
|
883
|
+
lastSync: new Date().toISOString(),
|
|
884
|
+
files: {
|
|
885
|
+
"docs/handoff.md": {
|
|
886
|
+
hash: "old-hash-from-last-sync",
|
|
887
|
+
size: 20,
|
|
888
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
889
|
+
direction: "down",
|
|
890
|
+
},
|
|
891
|
+
},
|
|
892
|
+
}));
|
|
893
|
+
const resolveSpy = vi
|
|
894
|
+
.spyOn(conflictModule, "resolveConflict")
|
|
895
|
+
.mockResolvedValue("skip");
|
|
896
|
+
try {
|
|
897
|
+
await sync({
|
|
898
|
+
company: "acme",
|
|
899
|
+
vaultConfig: mockConfig,
|
|
900
|
+
hqRoot: tmpDir,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
finally {
|
|
904
|
+
resolveSpy.mockRestore();
|
|
905
|
+
}
|
|
906
|
+
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("local version");
|
|
907
|
+
});
|
|
526
908
|
it("a genuine download clears a stale localDiverges flag (entry is now truly local==remote)", async () => {
|
|
527
909
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
528
910
|
fs.mkdirSync(companyDocs, { recursive: true });
|
|
@@ -548,7 +930,7 @@ describe("sync", () => {
|
|
|
548
930
|
]);
|
|
549
931
|
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
550
932
|
expect(fs.existsSync(path.join(companyDocs, "handoff.md"))).toBe(true);
|
|
551
|
-
const journal =
|
|
933
|
+
const journal = journalAt(journalPath);
|
|
552
934
|
expect(journal.files["docs/handoff.md"]?.localDiverges).toBeFalsy();
|
|
553
935
|
});
|
|
554
936
|
it("leaves a downloaded unreadable link unjournaled without emitting a fatal error", async () => {
|
|
@@ -587,7 +969,7 @@ describe("sync", () => {
|
|
|
587
969
|
samplePaths: [linkKey],
|
|
588
970
|
});
|
|
589
971
|
expect(events.some((event) => event.type === "error")).toBe(false);
|
|
590
|
-
const journal =
|
|
972
|
+
const journal = journalAt(journalPath);
|
|
591
973
|
expect(journal.files[linkKey]).toBeUndefined();
|
|
592
974
|
}
|
|
593
975
|
finally {
|
|
@@ -685,7 +1067,7 @@ describe("sync", () => {
|
|
|
685
1067
|
expect(litter).toEqual([]);
|
|
686
1068
|
// Journal re-stamped off the stale baseline (hash now matches local,
|
|
687
1069
|
// remoteEtag recorded) so the phantom conflict can't re-fire.
|
|
688
|
-
const journalAfter =
|
|
1070
|
+
const journalAfter = journalAt(journalPath);
|
|
689
1071
|
expect(journalAfter.files["docs/handoff.md"].hash).not.toBe("stale-hash");
|
|
690
1072
|
expect(journalAfter.files["docs/handoff.md"].remoteEtag).toBeTruthy();
|
|
691
1073
|
});
|
|
@@ -743,7 +1125,7 @@ describe("sync", () => {
|
|
|
743
1125
|
});
|
|
744
1126
|
expect(events.some((event) => event.type === "error")).toBe(false);
|
|
745
1127
|
expect(fs.readFileSync(localPath, "utf-8")).toBe("local version");
|
|
746
|
-
const journal =
|
|
1128
|
+
const journal = journalAt(journalPath);
|
|
747
1129
|
expect(journal.files[linkKey].hash).toBe("stale-hash");
|
|
748
1130
|
}
|
|
749
1131
|
finally {
|
|
@@ -782,13 +1164,10 @@ describe("sync", () => {
|
|
|
782
1164
|
});
|
|
783
1165
|
expect(result.conflicts).toBe(1);
|
|
784
1166
|
expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
|
|
785
|
-
//
|
|
786
|
-
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("
|
|
787
|
-
//
|
|
788
|
-
|
|
789
|
-
.readdirSync(companyDocs)
|
|
790
|
-
.filter((f) => f.includes(".conflict-"));
|
|
791
|
-
expect(litter).toHaveLength(1);
|
|
1167
|
+
// Cloud body adopted at the original name on keep…
|
|
1168
|
+
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("mock file content");
|
|
1169
|
+
// …and exactly one sidecar retained, holding the displaced local body.
|
|
1170
|
+
expect(fs.readFileSync(sidecarIn(companyDocs), "utf-8")).toBe("local version");
|
|
792
1171
|
});
|
|
793
1172
|
it("cloud-authoritative file is PULL-WINS on conflict: overwrites local, no mirror, no false-stamp", async () => {
|
|
794
1173
|
// company-brief.md is server-regenerated (gardener), so a divergence is not
|
|
@@ -901,10 +1280,12 @@ describe("sync", () => {
|
|
|
901
1280
|
// Surfaced as a conflict, NOT a silent download.
|
|
902
1281
|
expect(result.conflicts).toBe(1);
|
|
903
1282
|
expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
|
|
904
|
-
expect(result.filesDownloaded).toBe(
|
|
905
|
-
// The newer local bytes are
|
|
906
|
-
|
|
907
|
-
//
|
|
1283
|
+
expect(result.filesDownloaded).toBe(1);
|
|
1284
|
+
// The newer local bytes are not lost — the rollback is survivable because
|
|
1285
|
+
// `keep` renames them aside rather than discarding them. The guard's job is
|
|
1286
|
+
// to route this through conflict resolution instead of a silent download;
|
|
1287
|
+
// which side ends up at the original name is the strategy's call.
|
|
1288
|
+
expect(fs.readFileSync(sidecarIn(path.dirname(localPath)), "utf-8")).toBe(localContent);
|
|
908
1289
|
const litter = fs
|
|
909
1290
|
.readdirSync(companyDocs)
|
|
910
1291
|
.filter((f) => f.includes(".conflict-"));
|
|
@@ -964,8 +1345,8 @@ describe("sync", () => {
|
|
|
964
1345
|
});
|
|
965
1346
|
expect(result.conflicts).toBe(1);
|
|
966
1347
|
expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
|
|
967
|
-
expect(result.filesDownloaded).toBe(
|
|
968
|
-
expect(fs.readFileSync(localPath, "utf-8")).toBe(localEdit);
|
|
1348
|
+
expect(result.filesDownloaded).toBe(1);
|
|
1349
|
+
expect(fs.readFileSync(sidecarIn(path.dirname(localPath)), "utf-8")).toBe(localEdit);
|
|
969
1350
|
});
|
|
970
1351
|
it("anti-rollback guard is direction-gated: a SMALLER remote on a clean CONSUMER copy (direction=down) downloads, not conflict", async () => {
|
|
971
1352
|
// The device only ever PULLED this file (direction: "down") — a consumer
|
|
@@ -1089,18 +1470,16 @@ describe("sync", () => {
|
|
|
1089
1470
|
// Surfaced as a conflict, NOT a silent download.
|
|
1090
1471
|
expect(result.conflicts).toBe(1);
|
|
1091
1472
|
expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
|
|
1092
|
-
expect(result.filesDownloaded).toBe(
|
|
1093
|
-
// The local
|
|
1094
|
-
|
|
1095
|
-
//
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
expect(litter).toHaveLength(1);
|
|
1100
|
-
expect(fs.readFileSync(path.join(companyDocs, litter[0]), "utf-8")).toBe("mock file content");
|
|
1473
|
+
expect(result.filesDownloaded).toBe(1);
|
|
1474
|
+
// The unjournaled local body is not lost — it is renamed aside, which is
|
|
1475
|
+
// what makes adopting the cloud copy survivable for a file that had no
|
|
1476
|
+
// sync history at all.
|
|
1477
|
+
expect(fs.readFileSync(sidecarIn(path.dirname(localPath)), "utf-8")).toBe(localContent);
|
|
1478
|
+
// The cloud body took the original name.
|
|
1479
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
|
|
1101
1480
|
// And a durable record exists so the overwrite can never be silent.
|
|
1102
1481
|
const index = JSON.parse(fs.readFileSync(path.join(tmpDir, ".hq-conflicts", "index.json"), "utf-8"));
|
|
1103
|
-
expect(index.conflicts.some((c) => c.originalPath === "companies/acme/docs/handoff.md")).toBe(true);
|
|
1482
|
+
expect(index.conflicts.some((c) => c.originalPath === "companies/acme/docs/handoff.md" && c.preserved === "local")).toBe(true);
|
|
1104
1483
|
});
|
|
1105
1484
|
it("untracked-local guard is free on first pull: an unjournaled local file that already MATCHES the cloud reconciles silently", async () => {
|
|
1106
1485
|
// The guard must not manufacture conflicts for the common case — a fresh
|
|
@@ -1131,7 +1510,7 @@ describe("sync", () => {
|
|
|
1131
1510
|
.filter((f) => f.includes(".conflict-"));
|
|
1132
1511
|
expect(litter).toHaveLength(0);
|
|
1133
1512
|
// Journal stamped, so the next pass is a plain unchanged-skip.
|
|
1134
|
-
const journal =
|
|
1513
|
+
const journal = journalAt(journalPath);
|
|
1135
1514
|
expect(journal.files["docs/handoff.md"]).toBeDefined();
|
|
1136
1515
|
});
|
|
1137
1516
|
it("RF-F02EXEC: refuses a download whose parent symlink appears after planning", async () => {
|
|
@@ -1291,7 +1670,7 @@ describe("sync", () => {
|
|
|
1291
1670
|
});
|
|
1292
1671
|
expect(fs.readFileSync(untrackedPath, "utf-8")).toBe("brand new local work");
|
|
1293
1672
|
expect(fs.existsSync(trackedPath)).toBe(false);
|
|
1294
|
-
const journal =
|
|
1673
|
+
const journal = journalAt(journalPath);
|
|
1295
1674
|
expect(journal.files[untrackedKey]).toBeUndefined();
|
|
1296
1675
|
expect(journal.files[trackedKey]).toBeUndefined();
|
|
1297
1676
|
});
|
|
@@ -1364,7 +1743,7 @@ describe("sync", () => {
|
|
|
1364
1743
|
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1365
1744
|
expect(s3Module.downloadFile).toHaveBeenCalledWith(expect.anything(), key, expect.anything(), expect.anything());
|
|
1366
1745
|
expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
|
|
1367
|
-
const journal =
|
|
1746
|
+
const journal = journalAt(journalPath);
|
|
1368
1747
|
expect(journal.files[key]?.removedAt).toBeUndefined();
|
|
1369
1748
|
});
|
|
1370
1749
|
it("does not canonicalize ignored remote-missing journal keys in the tombstone sweep", async () => {
|
|
@@ -1412,7 +1791,7 @@ describe("sync", () => {
|
|
|
1412
1791
|
// Equivalence: an ignored key is never tombstoned — the local file and its
|
|
1413
1792
|
// journal entry both survive, exactly as before the fix.
|
|
1414
1793
|
expect(fs.existsSync(ignoredLocal)).toBe(true);
|
|
1415
|
-
const sweptJournal =
|
|
1794
|
+
const sweptJournal = journalAt(journalPath);
|
|
1416
1795
|
expect(sweptJournal.files[ignoredKey]).toBeDefined();
|
|
1417
1796
|
});
|
|
1418
1797
|
it("tombstone lookup failure does not resurrect a current intent-backed delete", async () => {
|
|
@@ -1446,7 +1825,7 @@ describe("sync", () => {
|
|
|
1446
1825
|
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1447
1826
|
expect(s3Module.downloadFile).not.toHaveBeenCalledWith(expect.anything(), key, expect.anything(), expect.anything());
|
|
1448
1827
|
expect(fs.existsSync(localPath)).toBe(false);
|
|
1449
|
-
expect(
|
|
1828
|
+
expect(journalAt(journalPath).files[key]
|
|
1450
1829
|
?.removedReason).toBe("local-delete");
|
|
1451
1830
|
});
|
|
1452
1831
|
it("pull producer: a BULK disappearance is drift-restored, NOT tombstoned (ridge-incident guard in the pull leg)", async () => {
|
|
@@ -1482,7 +1861,7 @@ describe("sync", () => {
|
|
|
1482
1861
|
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1483
1862
|
// All restored, none tombstoned.
|
|
1484
1863
|
expect(s3Module.downloadFile).toHaveBeenCalledTimes(12);
|
|
1485
|
-
const journal =
|
|
1864
|
+
const journal = journalAt(journalPath);
|
|
1486
1865
|
for (const k of keys) {
|
|
1487
1866
|
expect(journal.files[k]?.removedAt).toBeUndefined();
|
|
1488
1867
|
}
|
|
@@ -1523,7 +1902,7 @@ describe("sync", () => {
|
|
|
1523
1902
|
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1524
1903
|
expect(s3Module.downloadFile).not.toHaveBeenCalledWith(expect.anything(), key, expect.anything());
|
|
1525
1904
|
expect(fs.existsSync(localPath)).toBe(false);
|
|
1526
|
-
const journal =
|
|
1905
|
+
const journal = journalAt(journalPath);
|
|
1527
1906
|
// Entry PRESERVED as a tombstone, not removed.
|
|
1528
1907
|
expect(journal.files[key]?.removedAt).toBeTruthy();
|
|
1529
1908
|
expect(journal.files[key]?.removedReason).toBe("local-delete");
|
|
@@ -1555,7 +1934,7 @@ describe("sync", () => {
|
|
|
1555
1934
|
it("stamps journal.lastSync on every successful run, even when nothing transferred", async () => {
|
|
1556
1935
|
// First run downloads both remote files and stamps lastSync.
|
|
1557
1936
|
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1558
|
-
const firstStamp =
|
|
1937
|
+
const firstStamp = journalAt(journalPath).lastSync;
|
|
1559
1938
|
expect(firstStamp).not.toBe("");
|
|
1560
1939
|
// Second run: same remote, same local — Stage-2 plan is all-skip, so
|
|
1561
1940
|
// updateEntry never fires. lastSync must still advance, otherwise the
|
|
@@ -1563,7 +1942,7 @@ describe("sync", () => {
|
|
|
1563
1942
|
await new Promise((r) => setTimeout(r, 5));
|
|
1564
1943
|
const result = await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1565
1944
|
expect(result.filesDownloaded).toBe(0);
|
|
1566
|
-
const secondStamp =
|
|
1945
|
+
const secondStamp = journalAt(journalPath).lastSync;
|
|
1567
1946
|
expect(new Date(secondStamp).getTime()).toBeGreaterThan(new Date(firstStamp).getTime());
|
|
1568
1947
|
});
|
|
1569
1948
|
it("journalSlug: 'personal' routes journal I/O to sync-journal.personal.json", async () => {
|
|
@@ -1654,13 +2033,9 @@ describe("sync", () => {
|
|
|
1654
2033
|
expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "bar.md"))).toBe(false);
|
|
1655
2034
|
// The whole point: it now gets a journal baseline, so the push side stops
|
|
1656
2035
|
// re-firing a transient conflict every sync (the bug this fix closes).
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
.some((f) => {
|
|
1661
|
-
const j = JSON.parse(fs.readFileSync(path.join(stateDir, f), "utf8"));
|
|
1662
|
-
return j.files?.["companies/manifest.yaml"] != null;
|
|
1663
|
-
});
|
|
2036
|
+
// Enumerate through the API: the per-slug JSON files are locators now, so
|
|
2037
|
+
// reading them directly finds no `files` map at all.
|
|
2038
|
+
const journaledManifest = listJournals().some((j) => j.journal.files?.["companies/manifest.yaml"] != null);
|
|
1664
2039
|
expect(journaledManifest).toBe(true);
|
|
1665
2040
|
});
|
|
1666
2041
|
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 () => {
|
|
@@ -1754,9 +2129,10 @@ describe("sync", () => {
|
|
|
1754
2129
|
});
|
|
1755
2130
|
expect(result.conflicts).toBe(1);
|
|
1756
2131
|
expect(result.conflictPaths).toEqual(["workspace/threads/handoff.json"]);
|
|
1757
|
-
|
|
1758
|
-
//
|
|
1759
|
-
|
|
2132
|
+
// B's newer local pointer is preserved verbatim — renamed aside rather than
|
|
2133
|
+
// destroyed. DEV-1778 was about the pointer being LOST; a recoverable
|
|
2134
|
+
// sidecar satisfies that, and the machine can still resume from it.
|
|
2135
|
+
const kept = JSON.parse(fs.readFileSync(sidecarIn(threadsLocal), "utf-8"));
|
|
1760
2136
|
expect(kept.message).toBe("newer local from B");
|
|
1761
2137
|
expect(kept.thread_path).toBe("workspace/threads/T-machineB.json");
|
|
1762
2138
|
});
|
|
@@ -1913,7 +2289,7 @@ describe("sync", () => {
|
|
|
1913
2289
|
expect(result.filesTombstoned).toBeGreaterThanOrEqual(1);
|
|
1914
2290
|
// Journal entry for the deleted path must be gone too — otherwise
|
|
1915
2291
|
// the next sync would tombstone the same key on every run.
|
|
1916
|
-
const journal =
|
|
2292
|
+
const journal = journalAt(journalPath);
|
|
1917
2293
|
expect(journal.files["docs/deleted-by-peer.md"]).toBeUndefined();
|
|
1918
2294
|
expect(journal.files["docs/kept.md"]).toBeDefined();
|
|
1919
2295
|
});
|
|
@@ -1978,7 +2354,7 @@ describe("sync", () => {
|
|
|
1978
2354
|
expect(result.filesTombstoned).toBe(0);
|
|
1979
2355
|
// The journal is healed: the malformed key is gone, the canonical POSIX key
|
|
1980
2356
|
// points at the live file (so a future pull stays converged, not poisoned).
|
|
1981
|
-
const journal =
|
|
2357
|
+
const journal = journalAt(journalPath);
|
|
1982
2358
|
expect(journal.files[backslashKey]).toBeUndefined();
|
|
1983
2359
|
expect(journal.files[posixKey]).toBeDefined();
|
|
1984
2360
|
expect(Object.keys(journal.files).some((k) => k.includes("\\"))).toBe(false);
|
|
@@ -2031,7 +2407,7 @@ describe("sync", () => {
|
|
|
2031
2407
|
expect(result.filesTombstoned).toBe(0);
|
|
2032
2408
|
// Journal entry retained — next sync (push) will re-evaluate and
|
|
2033
2409
|
// either re-upload the divergent local or surface as a conflict.
|
|
2034
|
-
const journal =
|
|
2410
|
+
const journal = journalAt(journalPath);
|
|
2035
2411
|
expect(journal.files["docs/edited-locally.md"]).toBeDefined();
|
|
2036
2412
|
expect(journal.files["docs/edited-locally.md"].hash).toBe(baselineHash);
|
|
2037
2413
|
});
|
|
@@ -2075,7 +2451,7 @@ describe("sync", () => {
|
|
|
2075
2451
|
expect(result.filesTombstoned).toBe(0);
|
|
2076
2452
|
expect(fs.existsSync(racedPath)).toBe(true);
|
|
2077
2453
|
expect(fs.readFileSync(racedPath, "utf-8")).toBe("concurrent local edit");
|
|
2078
|
-
const journal =
|
|
2454
|
+
const journal = journalAt(journalPath);
|
|
2079
2455
|
expect(journal.files["docs/racy-delete.md"]).toBeDefined();
|
|
2080
2456
|
expect(journal.files["docs/racy-delete.md"].hash).toBe(baselineHash);
|
|
2081
2457
|
});
|
|
@@ -2112,7 +2488,7 @@ describe("sync", () => {
|
|
|
2112
2488
|
expect(result.filesTombstoned).toBe(0);
|
|
2113
2489
|
expect(result.conflicts).toBe(1);
|
|
2114
2490
|
expect(fs.readFileSync(localPath, "utf-8")).toBe(baseline);
|
|
2115
|
-
const journal =
|
|
2491
|
+
const journal = journalAt(journalPath);
|
|
2116
2492
|
expect(journal.files["docs/created-after-plan.md"]).toBeDefined();
|
|
2117
2493
|
});
|
|
2118
2494
|
it("CAS: preserves a concurrent local edit when a staged download is ready to replace it", async () => {
|
|
@@ -2196,7 +2572,7 @@ describe("sync", () => {
|
|
|
2196
2572
|
propagateDeletePolicy: "all",
|
|
2197
2573
|
});
|
|
2198
2574
|
expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
|
|
2199
|
-
const journal =
|
|
2575
|
+
const journal = journalAt(personalJournalPath);
|
|
2200
2576
|
expect(journal.files["core/core.yaml"]).toBeDefined();
|
|
2201
2577
|
expect(journal.files[".claude/skills/core:demo/SKILL.md"]).toBeDefined();
|
|
2202
2578
|
});
|
|
@@ -2230,7 +2606,7 @@ describe("sync", () => {
|
|
|
2230
2606
|
});
|
|
2231
2607
|
expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
|
|
2232
2608
|
expect(result.filesRefusedStale).toBe(2);
|
|
2233
|
-
const journal =
|
|
2609
|
+
const journal = journalAt(journalPath);
|
|
2234
2610
|
expect(journal.files["docs/a.md"]).toBeDefined();
|
|
2235
2611
|
expect(journal.files["docs/b.md"]).toBeDefined();
|
|
2236
2612
|
});
|
|
@@ -2258,10 +2634,16 @@ describe("sync", () => {
|
|
|
2258
2634
|
},
|
|
2259
2635
|
pulls: [],
|
|
2260
2636
|
}));
|
|
2261
|
-
vi.mocked(s3Module.headRemoteFile)
|
|
2637
|
+
vi.mocked(s3Module.headRemoteFile)
|
|
2638
|
+
.mockResolvedValueOnce({
|
|
2262
2639
|
etag: "old-etag",
|
|
2263
2640
|
size: baseline.length,
|
|
2264
2641
|
lastModified: new Date(),
|
|
2642
|
+
})
|
|
2643
|
+
.mockResolvedValueOnce({
|
|
2644
|
+
etag: "peer-etag",
|
|
2645
|
+
size: "mock file content".length,
|
|
2646
|
+
lastModified: new Date(),
|
|
2265
2647
|
});
|
|
2266
2648
|
const preconditionError = Object.assign(new Error("remote changed"), {
|
|
2267
2649
|
name: "PreconditionFailed",
|
|
@@ -2277,7 +2659,15 @@ describe("sync", () => {
|
|
|
2277
2659
|
onConflict: "keep",
|
|
2278
2660
|
});
|
|
2279
2661
|
expect(result.conflictPaths).toContain("board.json");
|
|
2280
|
-
|
|
2662
|
+
// The conditional PUT still fails closed, but keep is cloud-wins: the
|
|
2663
|
+
// peer body takes the working name and the local append remains in the
|
|
2664
|
+
// sidecar for explicit resolution.
|
|
2665
|
+
expect(fs.readFileSync(boardPath, "utf-8")).toBe("mock file content");
|
|
2666
|
+
expect(fs.readFileSync(sidecarIn(companyRoot), "utf-8")).toBe(localAppend);
|
|
2667
|
+
expect(journalAt(journalPath).files["board.json"]).toMatchObject({
|
|
2668
|
+
remoteEtag: "peer-etag",
|
|
2669
|
+
direction: "down",
|
|
2670
|
+
});
|
|
2281
2671
|
expect(s3Module.uploadFile).toHaveBeenCalledWith(expect.anything(), boardPath, "board.json", undefined, { ifMatch: "old-etag" });
|
|
2282
2672
|
});
|
|
2283
2673
|
it("does NOT tombstone symlinks whose readlink target has diverged from the journal (Codex P1 round 4)", async () => {
|
|
@@ -2322,7 +2712,7 @@ describe("sync", () => {
|
|
|
2322
2712
|
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
|
2323
2713
|
expect(fs.readlinkSync(linkPath)).toBe("new-target.md");
|
|
2324
2714
|
expect(result.filesTombstoned).toBe(0);
|
|
2325
|
-
const journal =
|
|
2715
|
+
const journal = journalAt(journalPath);
|
|
2326
2716
|
expect(journal.files["docs/link.md"]).toBeDefined();
|
|
2327
2717
|
expect(journal.files["docs/link.md"].hash).toBe(baselineHash);
|
|
2328
2718
|
});
|
|
@@ -2389,7 +2779,7 @@ describe("sync", () => {
|
|
|
2389
2779
|
// The really-deleted file MUST be tombstoned.
|
|
2390
2780
|
expect(fs.existsSync(reallyDeletedPath)).toBe(false);
|
|
2391
2781
|
expect(result.filesTombstoned).toBe(1);
|
|
2392
|
-
const journal =
|
|
2782
|
+
const journal = journalAt(journalPath);
|
|
2393
2783
|
// Journal entry retained for the invisible key (so next sync at
|
|
2394
2784
|
// broader scope can re-evaluate).
|
|
2395
2785
|
expect(journal.files["docs/invisible-to-list.md"]).toBeDefined();
|
|
@@ -2463,7 +2853,7 @@ describe("sync", () => {
|
|
|
2463
2853
|
expect(result.filesDownloaded).toBe(0);
|
|
2464
2854
|
expect(result.filesTombstoned).toBeGreaterThanOrEqual(1);
|
|
2465
2855
|
// Journal entry dropped so it converges (no re-tombstone next run).
|
|
2466
|
-
const journal =
|
|
2856
|
+
const journal = journalAt(journalPath);
|
|
2467
2857
|
expect(journal.files["docs/shared.md"]).toBeUndefined();
|
|
2468
2858
|
});
|
|
2469
2859
|
it("delete-resync: a re-created object NEWER than the tombstone DOES sync (tombstone is not permanent)", async () => {
|
|
@@ -2555,7 +2945,7 @@ describe("sync", () => {
|
|
|
2555
2945
|
expect(result.conflicts).toBeGreaterThanOrEqual(1);
|
|
2556
2946
|
expect(result.conflictPaths).toContain("docs/edited.md");
|
|
2557
2947
|
expect(fs.existsSync(editedPath)).toBe(true);
|
|
2558
|
-
expect(fs.readFileSync(editedPath, "utf-8")).toBe("my local edits");
|
|
2948
|
+
expect(fs.readFileSync(sidecarIn(path.dirname(editedPath)), "utf-8")).toBe("my local edits");
|
|
2559
2949
|
expect(result.filesTombstoned).toBe(0);
|
|
2560
2950
|
});
|
|
2561
2951
|
it("delete-resync: suppression is per-key and the fetch is company-scoped (no cross-tenant bleed)", async () => {
|
|
@@ -2627,7 +3017,7 @@ describe("sync", () => {
|
|
|
2627
3017
|
});
|
|
2628
3018
|
expect(fs.existsSync(deniedPath)).toBe(true);
|
|
2629
3019
|
expect(result.filesTombstoned).toBe(0);
|
|
2630
|
-
const journal =
|
|
3020
|
+
const journal = journalAt(journalPath);
|
|
2631
3021
|
expect(journal.files["docs/out-of-scope.md"]).toBeDefined();
|
|
2632
3022
|
});
|
|
2633
3023
|
it("retains the journal entry when tombstone unlink fails with EACCES (Codex P1 — Bug #9 follow-up)", async () => {
|
|
@@ -2675,7 +3065,7 @@ describe("sync", () => {
|
|
|
2675
3065
|
// Tombstone NOT counted — the delete wasn't honored.
|
|
2676
3066
|
expect(result.filesTombstoned).toBe(0);
|
|
2677
3067
|
// Journal entry MUST be retained so the next sync retries.
|
|
2678
|
-
const journal =
|
|
3068
|
+
const journal = journalAt(journalPath);
|
|
2679
3069
|
expect(journal.files["docs/locked-by-peer.md"]).toBeDefined();
|
|
2680
3070
|
expect(journal.files["docs/locked-by-peer.md"].hash).toBe("locked-hash");
|
|
2681
3071
|
}
|
|
@@ -2902,7 +3292,7 @@ describe("sync", () => {
|
|
|
2902
3292
|
expect(s3Module.downloadFile).not.toHaveBeenCalled();
|
|
2903
3293
|
expect(fs.existsSync(escapedPath)).toBe(false);
|
|
2904
3294
|
expect(fs.existsSync(path.join(companyRoot, traversalKey))).toBe(false);
|
|
2905
|
-
const journal =
|
|
3295
|
+
const journal = journalAt(journalPath);
|
|
2906
3296
|
expect(journal.files[traversalKey]).toBeUndefined();
|
|
2907
3297
|
}
|
|
2908
3298
|
finally {
|
|
@@ -2936,7 +3326,7 @@ describe("sync", () => {
|
|
|
2936
3326
|
expect(s3Module.downloadFile).not.toHaveBeenCalled();
|
|
2937
3327
|
expect(fs.existsSync(escapedPath)).toBe(false);
|
|
2938
3328
|
expect(fs.existsSync(path.join(companyRoot, remoteKey))).toBe(false);
|
|
2939
|
-
const journal =
|
|
3329
|
+
const journal = journalAt(journalPath);
|
|
2940
3330
|
expect(journal.files[remoteKey]).toBeUndefined();
|
|
2941
3331
|
}
|
|
2942
3332
|
finally {
|
|
@@ -3011,10 +3401,46 @@ describe("sync", () => {
|
|
|
3011
3401
|
vaultConfig: mockConfig,
|
|
3012
3402
|
hqRoot: tmpDir,
|
|
3013
3403
|
});
|
|
3014
|
-
const journal =
|
|
3404
|
+
const journal = journalAt(journalPath);
|
|
3015
3405
|
expect(journal.files["docs/handoff.md"].remoteEtag).toBe("abc123");
|
|
3016
3406
|
expect(journal.files["knowledge/readme.md"].remoteEtag).toBe("def456");
|
|
3017
3407
|
});
|
|
3408
|
+
it("persists each downloaded hash before starting the next file", async () => {
|
|
3409
|
+
process.env.HQ_SYNC_TRANSFER_CONCURRENCY = "1";
|
|
3410
|
+
const remoteFiles = [
|
|
3411
|
+
{ key: "ordered/first.md", size: 18, lastModified: new Date(), etag: '"first-etag"' },
|
|
3412
|
+
{ key: "ordered/second.md", size: 18, lastModified: new Date(), etag: '"second-etag"' },
|
|
3413
|
+
];
|
|
3414
|
+
const defaultDownload = vi.mocked(s3Module.downloadFile).getMockImplementation();
|
|
3415
|
+
if (!defaultDownload)
|
|
3416
|
+
throw new Error("missing default download mock");
|
|
3417
|
+
try {
|
|
3418
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(remoteFiles);
|
|
3419
|
+
vi.mocked(s3Module.downloadFile).mockImplementation(async (ctx, key, localPath, options) => {
|
|
3420
|
+
if (key === "ordered/second.md") {
|
|
3421
|
+
const first = journalAt(journalPath).files["ordered/first.md"];
|
|
3422
|
+
expect(first).toMatchObject({
|
|
3423
|
+
hash: crypto.createHash("sha256").update("mock file content").digest("hex"),
|
|
3424
|
+
remoteEtag: "first-etag",
|
|
3425
|
+
direction: "down",
|
|
3426
|
+
});
|
|
3427
|
+
expect(first.localDiverges).toBeFalsy();
|
|
3428
|
+
}
|
|
3429
|
+
return defaultDownload(ctx, key, localPath, options);
|
|
3430
|
+
});
|
|
3431
|
+
await sync({
|
|
3432
|
+
company: "acme",
|
|
3433
|
+
vaultConfig: mockConfig,
|
|
3434
|
+
hqRoot: tmpDir,
|
|
3435
|
+
skipReindex: true,
|
|
3436
|
+
onEvent: () => undefined,
|
|
3437
|
+
});
|
|
3438
|
+
}
|
|
3439
|
+
finally {
|
|
3440
|
+
vi.mocked(s3Module.downloadFile).mockImplementation(defaultDownload);
|
|
3441
|
+
delete process.env.HQ_SYNC_TRANSFER_CONCURRENCY;
|
|
3442
|
+
}
|
|
3443
|
+
});
|
|
3018
3444
|
it("checkpoints completed downloads so a failed bulk pass resumes only its tail", async () => {
|
|
3019
3445
|
process.env.HQ_SYNC_TRANSFER_CONCURRENCY = "1";
|
|
3020
3446
|
const remoteFiles = Array.from({ length: 9 }, (_, index) => ({
|
|
@@ -3032,7 +3458,7 @@ describe("sync", () => {
|
|
|
3032
3458
|
if (key === "bulk/file-8.md") {
|
|
3033
3459
|
// This models the next transfer crashing the process. The first
|
|
3034
3460
|
// eight entries must already be durable before the final writer.
|
|
3035
|
-
const checkpoint =
|
|
3461
|
+
const checkpoint = journalAt(journalPath);
|
|
3036
3462
|
expect(Object.keys(checkpoint.files)).toHaveLength(8);
|
|
3037
3463
|
throw new Error("simulated crash after checkpoint");
|
|
3038
3464
|
}
|
|
@@ -3046,7 +3472,7 @@ describe("sync", () => {
|
|
|
3046
3472
|
onEvent: () => undefined,
|
|
3047
3473
|
});
|
|
3048
3474
|
expect(first.filesDownloaded).toBe(8);
|
|
3049
|
-
const checkpoint =
|
|
3475
|
+
const checkpoint = journalAt(journalPath);
|
|
3050
3476
|
expect(Object.keys(checkpoint.files)).toHaveLength(8);
|
|
3051
3477
|
vi.mocked(s3Module.downloadFile).mockImplementation(defaultDownload);
|
|
3052
3478
|
vi.mocked(s3Module.downloadFile).mockClear();
|
|
@@ -3497,7 +3923,7 @@ describe("sync", () => {
|
|
|
3497
3923
|
// skip-unchanged works on the next tick.
|
|
3498
3924
|
const journalMod = await import("../journal.js");
|
|
3499
3925
|
const expectedHash = journalMod.hashSymlinkTarget(linkTarget);
|
|
3500
|
-
const journal =
|
|
3926
|
+
const journal = journalAt(journalPath);
|
|
3501
3927
|
expect(journal.files[linkKey]).toMatchObject({
|
|
3502
3928
|
hash: expectedHash,
|
|
3503
3929
|
size: 0,
|
|
@@ -3829,7 +4255,7 @@ describe("sync", () => {
|
|
|
3829
4255
|
expect(errOutput).toContain("manual");
|
|
3830
4256
|
expect(errOutput).not.toContain("directory-overlay marker");
|
|
3831
4257
|
});
|
|
3832
|
-
it("shared-mode leg
|
|
4258
|
+
it("shared-mode leg withholds out-of-scope keys at LIST time (scoped listing supersedes the feedback_d2082110 gap advisory)", async () => {
|
|
3833
4259
|
// Reporter feedback_d2082110 (cody@jonesroadbeauty.com): four memberships
|
|
3834
4260
|
// were configured as `shared` rather than `all`, so not all vault content
|
|
3835
4261
|
// materialized on the second device — a SILENT gap the reporter had to
|
|
@@ -3862,16 +4288,19 @@ describe("sync", () => {
|
|
|
3862
4288
|
onEvent: (e) => events.push(e),
|
|
3863
4289
|
});
|
|
3864
4290
|
// The in-scope key pulled; the out-of-scope key was NOT materialized.
|
|
4291
|
+
// BEHAVIOR CHANGE (targeted-sync scoped listing): the shared leg lists
|
|
4292
|
+
// ONLY its in-scope prefixes now, so the out-of-scope key never reaches
|
|
4293
|
+
// the plan — filesOutOfScope is 0 and the scope-materialization-gap
|
|
4294
|
+
// advisory (which counted plan-visible withheld keys) has nothing to
|
|
4295
|
+
// report. The advisory's classification logic remains for full listings;
|
|
4296
|
+
// the memory win of not materializing the whole vault LIST supersedes the
|
|
4297
|
+
// withheld-count telemetry on scoped legs.
|
|
3865
4298
|
expect(result.filesDownloaded).toBe(1);
|
|
3866
|
-
expect(result.filesOutOfScope).toBe(
|
|
4299
|
+
expect(result.filesOutOfScope).toBe(0);
|
|
3867
4300
|
expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "knowledge", "present.md"))).toBe(true);
|
|
3868
4301
|
expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "projects", "secret.md"))).toBe(false);
|
|
3869
|
-
// The gap is now SURFACED with the count, the active mode, and the lever.
|
|
3870
4302
|
const gap = events.find((e) => e.type === "scope-materialization-gap");
|
|
3871
|
-
expect(gap).
|
|
3872
|
-
expect(gap.count).toBe(1);
|
|
3873
|
-
expect(gap.syncMode).toBe("shared");
|
|
3874
|
-
expect(gap.samplePaths).toEqual(["projects/secret.md"]);
|
|
4303
|
+
expect(gap).toBeUndefined();
|
|
3875
4304
|
});
|
|
3876
4305
|
it("all-mode leg (default) never emits a materialization gap — no scoping, no noise", async () => {
|
|
3877
4306
|
// Boundary for the feedback_d2082110 gap surface: in `all` mode every key is
|
|
@@ -4144,7 +4573,7 @@ describe("sync", () => {
|
|
|
4144
4573
|
// The journal MUST be updated to reflect the user's keep
|
|
4145
4574
|
// decision. Pre-fix: catch swallowed the ENOENT, journal stayed
|
|
4146
4575
|
// at oldHash + old etag, every subsequent sync re-fired.
|
|
4147
|
-
const journal =
|
|
4576
|
+
const journal = journalAt(journalPath);
|
|
4148
4577
|
const entry = journal.files[linkKey];
|
|
4149
4578
|
expect(entry.hash).toBe(expectedNewHash);
|
|
4150
4579
|
expect(entry.size).toBe(0);
|
|
@@ -4443,7 +4872,7 @@ describe("scope-invalid key hardening (companies/ prefix in a company vault —
|
|
|
4443
4872
|
// Cleaned, not re-uploaded: local doubled-tree copy gone, journal dropped.
|
|
4444
4873
|
expect(fs.existsSync(path.join(doubled, "poison.md"))).toBe(false);
|
|
4445
4874
|
expect(result.filesTombstoned).toBeGreaterThanOrEqual(1);
|
|
4446
|
-
const journal =
|
|
4875
|
+
const journal = journalAt(journalPath);
|
|
4447
4876
|
expect(journal.files["companies/acme/knowledge/poison.md"]).toBeUndefined();
|
|
4448
4877
|
// No HEAD verify against the invalid key — the server validator would
|
|
4449
4878
|
// reject it (INVALID_KEY_COMPANIES_SCOPED) and defer the cleanup forever.
|
|
@@ -4571,7 +5000,7 @@ describe("junk key-spelling guard (US-002 mixed-version amplifier)", () => {
|
|
|
4571
5000
|
expect(events.filter((e) => e.type === "error")).toHaveLength(0);
|
|
4572
5001
|
expect(result.aborted).toBe(false);
|
|
4573
5002
|
expect(result.filesSkipped).toBe(1);
|
|
4574
|
-
const journalAfter =
|
|
5003
|
+
const journalAfter = journalAt(journalPath);
|
|
4575
5004
|
expect(Object.keys(journalAfter.files)).not.toContain(junkKey);
|
|
4576
5005
|
});
|
|
4577
5006
|
it("still pulls a junk-spelling key that is ALREADY journaled under that exact spelling", async () => {
|
|
@@ -4616,7 +5045,7 @@ describe("junk key-spelling guard (US-002 mixed-version amplifier)", () => {
|
|
|
4616
5045
|
// Junk twin still gated; the canonical journal entry tombstones out.
|
|
4617
5046
|
expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(1);
|
|
4618
5047
|
expect(result.filesTombstoned).toBe(1);
|
|
4619
|
-
const journalAfter =
|
|
5048
|
+
const journalAfter = journalAt(journalPath);
|
|
4620
5049
|
expect(Object.keys(journalAfter.files)).not.toContain(canonicalKey);
|
|
4621
5050
|
});
|
|
4622
5051
|
it("always downloads a CANONICAL remote key even when the journal holds only its junk twin (post-doctor convergence)", async () => {
|
|
@@ -4633,7 +5062,7 @@ describe("junk key-spelling guard (US-002 mixed-version amplifier)", () => {
|
|
|
4633
5062
|
expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(1);
|
|
4634
5063
|
expect(vi.mocked(s3Module.downloadFile).mock.calls[0][1]).toBe(canonicalKey);
|
|
4635
5064
|
expect(result.filesDownloaded).toBe(1);
|
|
4636
|
-
const journalAfter =
|
|
5065
|
+
const journalAfter = journalAt(journalPath);
|
|
4637
5066
|
expect(Object.keys(journalAfter.files)).toContain(canonicalKey);
|
|
4638
5067
|
});
|
|
4639
5068
|
it("does not tombstone a journaled canonical key when the LIST holds only its junk twin", async () => {
|
|
@@ -4649,7 +5078,7 @@ describe("junk key-spelling guard (US-002 mixed-version amplifier)", () => {
|
|
|
4649
5078
|
const { events, result } = await runSync();
|
|
4650
5079
|
// Junk twin skipped, canonical entry NOT tombstoned.
|
|
4651
5080
|
expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(1);
|
|
4652
|
-
const journalAfter =
|
|
5081
|
+
const journalAfter = journalAt(journalPath);
|
|
4653
5082
|
expect(Object.keys(journalAfter.files)).toContain(canonicalKey);
|
|
4654
5083
|
expect(result.filesTombstoned ?? 0).toBe(0);
|
|
4655
5084
|
expect(events.filter((e) => e.type === "error")).toHaveLength(0);
|