@indigoai-us/hq-cloud 6.15.30 → 6.15.31
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-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +24 -13
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.js +0 -71
- package/dist/bin/sync-runner-company.test.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +0 -10
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +13 -170
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +1 -16
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +2 -40
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.test.js +1 -53
- package/dist/bin/sync-runner-watch-routes.test.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +3 -15
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +13 -66
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +13 -433
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/conflict.d.ts +2 -25
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +4 -29
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/share.d.ts +4 -40
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +86 -478
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +150 -845
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +18 -147
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +0 -20
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +29 -261
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +90 -519
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +0 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +25 -155
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +11 -29
- package/dist/journal.test.js.map +1 -1
- package/dist/object-io.d.ts +13 -34
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +29 -154
- package/dist/object-io.js.map +1 -1
- package/dist/outcome-telemetry.d.ts +1 -7
- package/dist/outcome-telemetry.d.ts.map +1 -1
- package/dist/outcome-telemetry.js +9 -15
- package/dist/outcome-telemetry.js.map +1 -1
- package/dist/outcome-telemetry.test.js +0 -15
- 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 +3 -4
- package/dist/outposts/client.js.map +1 -1
- package/dist/outposts/command.d.ts.map +1 -1
- package/dist/outposts/command.js +6 -9
- package/dist/outposts/command.js.map +1 -1
- package/dist/outposts/command.test.js +2 -59
- 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 +8 -10
- package/dist/outposts/remote-command.js.map +1 -1
- package/dist/prefix-coalesce.d.ts +0 -14
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +0 -29
- package/dist/prefix-coalesce.js.map +1 -1
- package/dist/prefix-coalesce.test.js +1 -22
- package/dist/prefix-coalesce.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts +1 -6
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +8 -14
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +0 -43
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sts-credential-refresh.test.js +2 -87
- 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 +1 -20
- package/dist/sync/state-store.js.map +1 -1
- package/dist/sync/state-store.test.js +0 -64
- package/dist/sync/state-store.test.js.map +1 -1
- package/dist/sync-core.d.ts.map +1 -1
- package/dist/sync-core.js +2 -7
- package/dist/sync-core.js.map +1 -1
- package/dist/sync-core.test.js +0 -36
- package/dist/sync-core.test.js.map +1 -1
- package/dist/telemetry.d.ts +6 -30
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +135 -331
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +0 -403
- package/dist/telemetry.test.js.map +1 -1
- package/dist/types.d.ts +3 -26
- package/dist/types.d.ts.map +1 -1
- package/dist/watcher.d.ts +0 -76
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +3 -105
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +1 -165
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/dist/bandwidth.d.ts +0 -128
- package/dist/bandwidth.d.ts.map +0 -1
- package/dist/bandwidth.js +0 -254
- package/dist/bandwidth.js.map +0 -1
- package/dist/bandwidth.test.d.ts +0 -2
- package/dist/bandwidth.test.d.ts.map +0 -1
- package/dist/bandwidth.test.js +0 -243
- package/dist/bandwidth.test.js.map +0 -1
- package/dist/journal-delta-writes.test.d.ts +0 -2
- package/dist/journal-delta-writes.test.d.ts.map +0 -1
- package/dist/journal-delta-writes.test.js +0 -140
- package/dist/journal-delta-writes.test.js.map +0 -1
package/dist/cli/sync.test.js
CHANGED
|
@@ -5,7 +5,6 @@ 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";
|
|
9
8
|
import { clearContextCache } from "../context.js";
|
|
10
9
|
import { lockPathFor } from "../operation-lock.js";
|
|
11
10
|
// Re-export node:fs as a mutable module so unreadable-link regressions can
|
|
@@ -14,13 +13,6 @@ vi.mock("fs", async (importOriginal) => {
|
|
|
14
13
|
const actual = await importOriginal();
|
|
15
14
|
return { ...actual };
|
|
16
15
|
});
|
|
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
|
-
});
|
|
24
16
|
describe("sync mutation stdin boundary", () => {
|
|
25
17
|
it("accepts only the exact sidecar command and emits one discriminated upsert result", async () => {
|
|
26
18
|
expect(SYNC_MUTATION_STDIN_COMMAND).toEqual(["sync", "mutation", "--stdin-json"]);
|
|
@@ -123,37 +115,10 @@ vi.mock("./reindex.js", () => ({
|
|
|
123
115
|
import { sync, reportNewFilesToNotify, applyRealtimeDeltaPage, isSyncMutationStdinCommand, runSyncMutationStdinJson, SYNC_MUTATION_STDIN_COMMAND, win32SymlinkFlavorBroken, } from "./sync.js";
|
|
124
116
|
import { share } from "./share.js";
|
|
125
117
|
import * as s3Module from "../s3.js";
|
|
126
|
-
import * as conflictModule from "./conflict.js";
|
|
127
118
|
import { reindex } from "./reindex.js";
|
|
128
119
|
import { readSyncProgress } from "../sync-progress.js";
|
|
129
120
|
import { VaultAuthError } from "../vault-client.js";
|
|
130
|
-
import { readJournal, getEntry
|
|
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
|
-
}
|
|
121
|
+
import { readJournal, getEntry } from "../journal.js";
|
|
157
122
|
const mockConfig = {
|
|
158
123
|
apiUrl: "https://vault-api.test",
|
|
159
124
|
authToken: "test-jwt-token",
|
|
@@ -501,7 +466,7 @@ describe("sync", () => {
|
|
|
501
466
|
const result = await sync({ vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
502
467
|
expect(result.filesDownloaded).toBe(2);
|
|
503
468
|
});
|
|
504
|
-
it("detects conflicts with local changes and
|
|
469
|
+
it("detects conflicts with local changes and keeps local on --on-conflict keep", async () => {
|
|
505
470
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
506
471
|
fs.mkdirSync(companyDocs, { recursive: true });
|
|
507
472
|
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
|
|
@@ -525,66 +490,10 @@ describe("sync", () => {
|
|
|
525
490
|
});
|
|
526
491
|
expect(result.conflicts).toBe(1);
|
|
527
492
|
expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
|
|
528
|
-
|
|
529
|
-
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("
|
|
530
|
-
// …and the local one survives beside it.
|
|
531
|
-
expect(fs.readFileSync(sidecarIn(companyDocs), "utf-8")).toBe("local version");
|
|
532
|
-
});
|
|
533
|
-
it("rechecks the current local body when executing a planned conflict", async () => {
|
|
534
|
-
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
535
|
-
fs.mkdirSync(companyDocs, { recursive: true });
|
|
536
|
-
const localPath = path.join(companyDocs, "handoff.md");
|
|
537
|
-
fs.writeFileSync(localPath, "local body seen by the planner");
|
|
538
|
-
fs.writeFileSync(journalPath, JSON.stringify({
|
|
539
|
-
version: "1",
|
|
540
|
-
lastSync: new Date().toISOString(),
|
|
541
|
-
files: {
|
|
542
|
-
"docs/handoff.md": {
|
|
543
|
-
hash: "common-base-hash",
|
|
544
|
-
size: 16,
|
|
545
|
-
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
546
|
-
direction: "down",
|
|
547
|
-
remoteEtag: "common-base-etag",
|
|
548
|
-
},
|
|
549
|
-
},
|
|
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;
|
|
560
|
-
const result = await sync({
|
|
561
|
-
company: "acme",
|
|
562
|
-
onConflict: "keep",
|
|
563
|
-
vaultConfig: mockConfig,
|
|
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",
|
|
584
|
-
});
|
|
585
|
-
expect(entry.localDiverges).toBeFalsy();
|
|
493
|
+
expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
|
|
494
|
+
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("local version");
|
|
586
495
|
});
|
|
587
|
-
it("conflict
|
|
496
|
+
it("conflict keep marks the entry localDiverges (journal-honesty: kept copy never matched the recorded remote etag)", async () => {
|
|
588
497
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
589
498
|
fs.mkdirSync(companyDocs, { recursive: true });
|
|
590
499
|
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
|
|
@@ -600,311 +509,20 @@ describe("sync", () => {
|
|
|
600
509
|
},
|
|
601
510
|
},
|
|
602
511
|
}));
|
|
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
|
-
}
|
|
620
|
-
expect(result.conflicts).toBe(1);
|
|
621
|
-
const journal = journalAt(journalPath);
|
|
622
|
-
// remoteEtag still recorded so the conflict can't re-fire (#137 invariant)…
|
|
623
|
-
expect(journal.files["docs/handoff.md"].remoteEtag).toBeTruthy();
|
|
624
|
-
// …but the held local copy is flagged divergent so a later currency-gated
|
|
625
|
-
// delete cannot fire on a journal that lies about local==remote.
|
|
626
|
-
expect(journal.files["docs/handoff.md"].localDiverges).toBe(true);
|
|
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
512
|
const result = await sync({
|
|
651
513
|
company: "acme",
|
|
652
|
-
onConflict: "
|
|
514
|
+
onConflict: "keep",
|
|
653
515
|
vaultConfig: mockConfig,
|
|
654
516
|
hqRoot: tmpDir,
|
|
655
517
|
});
|
|
656
518
|
expect(result.conflicts).toBe(1);
|
|
657
|
-
|
|
658
|
-
//
|
|
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);
|
|
519
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
520
|
+
// remoteEtag still recorded so the conflict can't re-fire (#137 invariant)…
|
|
662
521
|
expect(journal.files["docs/handoff.md"].remoteEtag).toBeTruthy();
|
|
522
|
+
// …but the kept-local copy is flagged divergent so a later currency-gated
|
|
523
|
+
// delete cannot fire on a journal that lies about local==remote.
|
|
663
524
|
expect(journal.files["docs/handoff.md"].localDiverges).toBe(true);
|
|
664
525
|
});
|
|
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
|
-
});
|
|
908
526
|
it("a genuine download clears a stale localDiverges flag (entry is now truly local==remote)", async () => {
|
|
909
527
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
910
528
|
fs.mkdirSync(companyDocs, { recursive: true });
|
|
@@ -930,7 +548,7 @@ describe("sync", () => {
|
|
|
930
548
|
]);
|
|
931
549
|
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
932
550
|
expect(fs.existsSync(path.join(companyDocs, "handoff.md"))).toBe(true);
|
|
933
|
-
const journal =
|
|
551
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
934
552
|
expect(journal.files["docs/handoff.md"]?.localDiverges).toBeFalsy();
|
|
935
553
|
});
|
|
936
554
|
it("leaves a downloaded unreadable link unjournaled without emitting a fatal error", async () => {
|
|
@@ -969,7 +587,7 @@ describe("sync", () => {
|
|
|
969
587
|
samplePaths: [linkKey],
|
|
970
588
|
});
|
|
971
589
|
expect(events.some((event) => event.type === "error")).toBe(false);
|
|
972
|
-
const journal =
|
|
590
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
973
591
|
expect(journal.files[linkKey]).toBeUndefined();
|
|
974
592
|
}
|
|
975
593
|
finally {
|
|
@@ -1067,7 +685,7 @@ describe("sync", () => {
|
|
|
1067
685
|
expect(litter).toEqual([]);
|
|
1068
686
|
// Journal re-stamped off the stale baseline (hash now matches local,
|
|
1069
687
|
// remoteEtag recorded) so the phantom conflict can't re-fire.
|
|
1070
|
-
const journalAfter =
|
|
688
|
+
const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1071
689
|
expect(journalAfter.files["docs/handoff.md"].hash).not.toBe("stale-hash");
|
|
1072
690
|
expect(journalAfter.files["docs/handoff.md"].remoteEtag).toBeTruthy();
|
|
1073
691
|
});
|
|
@@ -1125,7 +743,7 @@ describe("sync", () => {
|
|
|
1125
743
|
});
|
|
1126
744
|
expect(events.some((event) => event.type === "error")).toBe(false);
|
|
1127
745
|
expect(fs.readFileSync(localPath, "utf-8")).toBe("local version");
|
|
1128
|
-
const journal =
|
|
746
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1129
747
|
expect(journal.files[linkKey].hash).toBe("stale-hash");
|
|
1130
748
|
}
|
|
1131
749
|
finally {
|
|
@@ -1164,10 +782,13 @@ describe("sync", () => {
|
|
|
1164
782
|
});
|
|
1165
783
|
expect(result.conflicts).toBe(1);
|
|
1166
784
|
expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
|
|
1167
|
-
//
|
|
1168
|
-
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("
|
|
1169
|
-
//
|
|
1170
|
-
|
|
785
|
+
// Local preserved on keep.
|
|
786
|
+
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("local version");
|
|
787
|
+
// Mirror retained for inspection.
|
|
788
|
+
const litter = fs
|
|
789
|
+
.readdirSync(companyDocs)
|
|
790
|
+
.filter((f) => f.includes(".conflict-"));
|
|
791
|
+
expect(litter).toHaveLength(1);
|
|
1171
792
|
});
|
|
1172
793
|
it("cloud-authoritative file is PULL-WINS on conflict: overwrites local, no mirror, no false-stamp", async () => {
|
|
1173
794
|
// company-brief.md is server-regenerated (gardener), so a divergence is not
|
|
@@ -1280,12 +901,10 @@ describe("sync", () => {
|
|
|
1280
901
|
// Surfaced as a conflict, NOT a silent download.
|
|
1281
902
|
expect(result.conflicts).toBe(1);
|
|
1282
903
|
expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
|
|
1283
|
-
expect(result.filesDownloaded).toBe(
|
|
1284
|
-
// The newer local bytes are
|
|
1285
|
-
|
|
1286
|
-
//
|
|
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);
|
|
904
|
+
expect(result.filesDownloaded).toBe(0);
|
|
905
|
+
// The newer local bytes are intact — nothing was rolled back.
|
|
906
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe(localContent);
|
|
907
|
+
// Remote preserved as an inspection mirror rather than overwriting local.
|
|
1289
908
|
const litter = fs
|
|
1290
909
|
.readdirSync(companyDocs)
|
|
1291
910
|
.filter((f) => f.includes(".conflict-"));
|
|
@@ -1345,8 +964,8 @@ describe("sync", () => {
|
|
|
1345
964
|
});
|
|
1346
965
|
expect(result.conflicts).toBe(1);
|
|
1347
966
|
expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
|
|
1348
|
-
expect(result.filesDownloaded).toBe(
|
|
1349
|
-
expect(fs.readFileSync(
|
|
967
|
+
expect(result.filesDownloaded).toBe(0);
|
|
968
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe(localEdit);
|
|
1350
969
|
});
|
|
1351
970
|
it("anti-rollback guard is direction-gated: a SMALLER remote on a clean CONSUMER copy (direction=down) downloads, not conflict", async () => {
|
|
1352
971
|
// The device only ever PULLED this file (direction: "down") — a consumer
|
|
@@ -1470,16 +1089,18 @@ describe("sync", () => {
|
|
|
1470
1089
|
// Surfaced as a conflict, NOT a silent download.
|
|
1471
1090
|
expect(result.conflicts).toBe(1);
|
|
1472
1091
|
expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
|
|
1473
|
-
expect(result.filesDownloaded).toBe(
|
|
1474
|
-
// The
|
|
1475
|
-
|
|
1476
|
-
//
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1092
|
+
expect(result.filesDownloaded).toBe(0);
|
|
1093
|
+
// The local edit is intact.
|
|
1094
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe(localContent);
|
|
1095
|
+
// Cloud copy preserved alongside it for inspection / `/resolve-conflicts`.
|
|
1096
|
+
const litter = fs
|
|
1097
|
+
.readdirSync(companyDocs)
|
|
1098
|
+
.filter((f) => f.includes(".conflict-"));
|
|
1099
|
+
expect(litter).toHaveLength(1);
|
|
1100
|
+
expect(fs.readFileSync(path.join(companyDocs, litter[0]), "utf-8")).toBe("mock file content");
|
|
1480
1101
|
// And a durable record exists so the overwrite can never be silent.
|
|
1481
1102
|
const index = JSON.parse(fs.readFileSync(path.join(tmpDir, ".hq-conflicts", "index.json"), "utf-8"));
|
|
1482
|
-
expect(index.conflicts.some((c) => c.originalPath === "companies/acme/docs/handoff.md"
|
|
1103
|
+
expect(index.conflicts.some((c) => c.originalPath === "companies/acme/docs/handoff.md")).toBe(true);
|
|
1483
1104
|
});
|
|
1484
1105
|
it("untracked-local guard is free on first pull: an unjournaled local file that already MATCHES the cloud reconciles silently", async () => {
|
|
1485
1106
|
// The guard must not manufacture conflicts for the common case — a fresh
|
|
@@ -1510,7 +1131,7 @@ describe("sync", () => {
|
|
|
1510
1131
|
.filter((f) => f.includes(".conflict-"));
|
|
1511
1132
|
expect(litter).toHaveLength(0);
|
|
1512
1133
|
// Journal stamped, so the next pass is a plain unchanged-skip.
|
|
1513
|
-
const journal =
|
|
1134
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1514
1135
|
expect(journal.files["docs/handoff.md"]).toBeDefined();
|
|
1515
1136
|
});
|
|
1516
1137
|
it("RF-F02EXEC: refuses a download whose parent symlink appears after planning", async () => {
|
|
@@ -1670,7 +1291,7 @@ describe("sync", () => {
|
|
|
1670
1291
|
});
|
|
1671
1292
|
expect(fs.readFileSync(untrackedPath, "utf-8")).toBe("brand new local work");
|
|
1672
1293
|
expect(fs.existsSync(trackedPath)).toBe(false);
|
|
1673
|
-
const journal =
|
|
1294
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1674
1295
|
expect(journal.files[untrackedKey]).toBeUndefined();
|
|
1675
1296
|
expect(journal.files[trackedKey]).toBeUndefined();
|
|
1676
1297
|
});
|
|
@@ -1743,7 +1364,7 @@ describe("sync", () => {
|
|
|
1743
1364
|
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1744
1365
|
expect(s3Module.downloadFile).toHaveBeenCalledWith(expect.anything(), key, expect.anything(), expect.anything());
|
|
1745
1366
|
expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
|
|
1746
|
-
const journal =
|
|
1367
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1747
1368
|
expect(journal.files[key]?.removedAt).toBeUndefined();
|
|
1748
1369
|
});
|
|
1749
1370
|
it("does not canonicalize ignored remote-missing journal keys in the tombstone sweep", async () => {
|
|
@@ -1791,7 +1412,7 @@ describe("sync", () => {
|
|
|
1791
1412
|
// Equivalence: an ignored key is never tombstoned — the local file and its
|
|
1792
1413
|
// journal entry both survive, exactly as before the fix.
|
|
1793
1414
|
expect(fs.existsSync(ignoredLocal)).toBe(true);
|
|
1794
|
-
const sweptJournal =
|
|
1415
|
+
const sweptJournal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1795
1416
|
expect(sweptJournal.files[ignoredKey]).toBeDefined();
|
|
1796
1417
|
});
|
|
1797
1418
|
it("tombstone lookup failure does not resurrect a current intent-backed delete", async () => {
|
|
@@ -1825,7 +1446,7 @@ describe("sync", () => {
|
|
|
1825
1446
|
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1826
1447
|
expect(s3Module.downloadFile).not.toHaveBeenCalledWith(expect.anything(), key, expect.anything(), expect.anything());
|
|
1827
1448
|
expect(fs.existsSync(localPath)).toBe(false);
|
|
1828
|
-
expect(
|
|
1449
|
+
expect(JSON.parse(fs.readFileSync(journalPath, "utf8")).files[key]
|
|
1829
1450
|
?.removedReason).toBe("local-delete");
|
|
1830
1451
|
});
|
|
1831
1452
|
it("pull producer: a BULK disappearance is drift-restored, NOT tombstoned (ridge-incident guard in the pull leg)", async () => {
|
|
@@ -1861,7 +1482,7 @@ describe("sync", () => {
|
|
|
1861
1482
|
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1862
1483
|
// All restored, none tombstoned.
|
|
1863
1484
|
expect(s3Module.downloadFile).toHaveBeenCalledTimes(12);
|
|
1864
|
-
const journal =
|
|
1485
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1865
1486
|
for (const k of keys) {
|
|
1866
1487
|
expect(journal.files[k]?.removedAt).toBeUndefined();
|
|
1867
1488
|
}
|
|
@@ -1902,7 +1523,7 @@ describe("sync", () => {
|
|
|
1902
1523
|
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1903
1524
|
expect(s3Module.downloadFile).not.toHaveBeenCalledWith(expect.anything(), key, expect.anything());
|
|
1904
1525
|
expect(fs.existsSync(localPath)).toBe(false);
|
|
1905
|
-
const journal =
|
|
1526
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1906
1527
|
// Entry PRESERVED as a tombstone, not removed.
|
|
1907
1528
|
expect(journal.files[key]?.removedAt).toBeTruthy();
|
|
1908
1529
|
expect(journal.files[key]?.removedReason).toBe("local-delete");
|
|
@@ -1934,7 +1555,7 @@ describe("sync", () => {
|
|
|
1934
1555
|
it("stamps journal.lastSync on every successful run, even when nothing transferred", async () => {
|
|
1935
1556
|
// First run downloads both remote files and stamps lastSync.
|
|
1936
1557
|
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1937
|
-
const firstStamp =
|
|
1558
|
+
const firstStamp = JSON.parse(fs.readFileSync(journalPath, "utf-8")).lastSync;
|
|
1938
1559
|
expect(firstStamp).not.toBe("");
|
|
1939
1560
|
// Second run: same remote, same local — Stage-2 plan is all-skip, so
|
|
1940
1561
|
// updateEntry never fires. lastSync must still advance, otherwise the
|
|
@@ -1942,7 +1563,7 @@ describe("sync", () => {
|
|
|
1942
1563
|
await new Promise((r) => setTimeout(r, 5));
|
|
1943
1564
|
const result = await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1944
1565
|
expect(result.filesDownloaded).toBe(0);
|
|
1945
|
-
const secondStamp =
|
|
1566
|
+
const secondStamp = JSON.parse(fs.readFileSync(journalPath, "utf-8")).lastSync;
|
|
1946
1567
|
expect(new Date(secondStamp).getTime()).toBeGreaterThan(new Date(firstStamp).getTime());
|
|
1947
1568
|
});
|
|
1948
1569
|
it("journalSlug: 'personal' routes journal I/O to sync-journal.personal.json", async () => {
|
|
@@ -2033,9 +1654,13 @@ describe("sync", () => {
|
|
|
2033
1654
|
expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "bar.md"))).toBe(false);
|
|
2034
1655
|
// The whole point: it now gets a journal baseline, so the push side stops
|
|
2035
1656
|
// re-firing a transient conflict every sync (the bug this fix closes).
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
1657
|
+
const journaledManifest = fs
|
|
1658
|
+
.readdirSync(stateDir)
|
|
1659
|
+
.filter((f) => f.startsWith("sync-journal."))
|
|
1660
|
+
.some((f) => {
|
|
1661
|
+
const j = JSON.parse(fs.readFileSync(path.join(stateDir, f), "utf8"));
|
|
1662
|
+
return j.files?.["companies/manifest.yaml"] != null;
|
|
1663
|
+
});
|
|
2039
1664
|
expect(journaledManifest).toBe(true);
|
|
2040
1665
|
});
|
|
2041
1666
|
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 () => {
|
|
@@ -2129,10 +1754,9 @@ describe("sync", () => {
|
|
|
2129
1754
|
});
|
|
2130
1755
|
expect(result.conflicts).toBe(1);
|
|
2131
1756
|
expect(result.conflictPaths).toEqual(["workspace/threads/handoff.json"]);
|
|
2132
|
-
|
|
2133
|
-
//
|
|
2134
|
-
|
|
2135
|
-
const kept = JSON.parse(fs.readFileSync(sidecarIn(threadsLocal), "utf-8"));
|
|
1757
|
+
expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
|
|
1758
|
+
// B's newer local pointer is preserved verbatim — not clobbered by A's.
|
|
1759
|
+
const kept = JSON.parse(fs.readFileSync(path.join(threadsLocal, "handoff.json"), "utf-8"));
|
|
2136
1760
|
expect(kept.message).toBe("newer local from B");
|
|
2137
1761
|
expect(kept.thread_path).toBe("workspace/threads/T-machineB.json");
|
|
2138
1762
|
});
|
|
@@ -2289,7 +1913,7 @@ describe("sync", () => {
|
|
|
2289
1913
|
expect(result.filesTombstoned).toBeGreaterThanOrEqual(1);
|
|
2290
1914
|
// Journal entry for the deleted path must be gone too — otherwise
|
|
2291
1915
|
// the next sync would tombstone the same key on every run.
|
|
2292
|
-
const journal =
|
|
1916
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2293
1917
|
expect(journal.files["docs/deleted-by-peer.md"]).toBeUndefined();
|
|
2294
1918
|
expect(journal.files["docs/kept.md"]).toBeDefined();
|
|
2295
1919
|
});
|
|
@@ -2354,7 +1978,7 @@ describe("sync", () => {
|
|
|
2354
1978
|
expect(result.filesTombstoned).toBe(0);
|
|
2355
1979
|
// The journal is healed: the malformed key is gone, the canonical POSIX key
|
|
2356
1980
|
// points at the live file (so a future pull stays converged, not poisoned).
|
|
2357
|
-
const journal =
|
|
1981
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2358
1982
|
expect(journal.files[backslashKey]).toBeUndefined();
|
|
2359
1983
|
expect(journal.files[posixKey]).toBeDefined();
|
|
2360
1984
|
expect(Object.keys(journal.files).some((k) => k.includes("\\"))).toBe(false);
|
|
@@ -2407,7 +2031,7 @@ describe("sync", () => {
|
|
|
2407
2031
|
expect(result.filesTombstoned).toBe(0);
|
|
2408
2032
|
// Journal entry retained — next sync (push) will re-evaluate and
|
|
2409
2033
|
// either re-upload the divergent local or surface as a conflict.
|
|
2410
|
-
const journal =
|
|
2034
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2411
2035
|
expect(journal.files["docs/edited-locally.md"]).toBeDefined();
|
|
2412
2036
|
expect(journal.files["docs/edited-locally.md"].hash).toBe(baselineHash);
|
|
2413
2037
|
});
|
|
@@ -2451,7 +2075,7 @@ describe("sync", () => {
|
|
|
2451
2075
|
expect(result.filesTombstoned).toBe(0);
|
|
2452
2076
|
expect(fs.existsSync(racedPath)).toBe(true);
|
|
2453
2077
|
expect(fs.readFileSync(racedPath, "utf-8")).toBe("concurrent local edit");
|
|
2454
|
-
const journal =
|
|
2078
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2455
2079
|
expect(journal.files["docs/racy-delete.md"]).toBeDefined();
|
|
2456
2080
|
expect(journal.files["docs/racy-delete.md"].hash).toBe(baselineHash);
|
|
2457
2081
|
});
|
|
@@ -2488,7 +2112,7 @@ describe("sync", () => {
|
|
|
2488
2112
|
expect(result.filesTombstoned).toBe(0);
|
|
2489
2113
|
expect(result.conflicts).toBe(1);
|
|
2490
2114
|
expect(fs.readFileSync(localPath, "utf-8")).toBe(baseline);
|
|
2491
|
-
const journal =
|
|
2115
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2492
2116
|
expect(journal.files["docs/created-after-plan.md"]).toBeDefined();
|
|
2493
2117
|
});
|
|
2494
2118
|
it("CAS: preserves a concurrent local edit when a staged download is ready to replace it", async () => {
|
|
@@ -2572,7 +2196,7 @@ describe("sync", () => {
|
|
|
2572
2196
|
propagateDeletePolicy: "all",
|
|
2573
2197
|
});
|
|
2574
2198
|
expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
|
|
2575
|
-
const journal =
|
|
2199
|
+
const journal = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
|
|
2576
2200
|
expect(journal.files["core/core.yaml"]).toBeDefined();
|
|
2577
2201
|
expect(journal.files[".claude/skills/core:demo/SKILL.md"]).toBeDefined();
|
|
2578
2202
|
});
|
|
@@ -2606,7 +2230,7 @@ describe("sync", () => {
|
|
|
2606
2230
|
});
|
|
2607
2231
|
expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
|
|
2608
2232
|
expect(result.filesRefusedStale).toBe(2);
|
|
2609
|
-
const journal =
|
|
2233
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2610
2234
|
expect(journal.files["docs/a.md"]).toBeDefined();
|
|
2611
2235
|
expect(journal.files["docs/b.md"]).toBeDefined();
|
|
2612
2236
|
});
|
|
@@ -2634,16 +2258,10 @@ describe("sync", () => {
|
|
|
2634
2258
|
},
|
|
2635
2259
|
pulls: [],
|
|
2636
2260
|
}));
|
|
2637
|
-
vi.mocked(s3Module.headRemoteFile)
|
|
2638
|
-
.mockResolvedValueOnce({
|
|
2261
|
+
vi.mocked(s3Module.headRemoteFile).mockResolvedValueOnce({
|
|
2639
2262
|
etag: "old-etag",
|
|
2640
2263
|
size: baseline.length,
|
|
2641
2264
|
lastModified: new Date(),
|
|
2642
|
-
})
|
|
2643
|
-
.mockResolvedValueOnce({
|
|
2644
|
-
etag: "peer-etag",
|
|
2645
|
-
size: "mock file content".length,
|
|
2646
|
-
lastModified: new Date(),
|
|
2647
2265
|
});
|
|
2648
2266
|
const preconditionError = Object.assign(new Error("remote changed"), {
|
|
2649
2267
|
name: "PreconditionFailed",
|
|
@@ -2659,15 +2277,7 @@ describe("sync", () => {
|
|
|
2659
2277
|
onConflict: "keep",
|
|
2660
2278
|
});
|
|
2661
2279
|
expect(result.conflictPaths).toContain("board.json");
|
|
2662
|
-
|
|
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
|
-
});
|
|
2280
|
+
expect(fs.readFileSync(boardPath, "utf-8")).toBe(localAppend);
|
|
2671
2281
|
expect(s3Module.uploadFile).toHaveBeenCalledWith(expect.anything(), boardPath, "board.json", undefined, { ifMatch: "old-etag" });
|
|
2672
2282
|
});
|
|
2673
2283
|
it("does NOT tombstone symlinks whose readlink target has diverged from the journal (Codex P1 round 4)", async () => {
|
|
@@ -2712,7 +2322,7 @@ describe("sync", () => {
|
|
|
2712
2322
|
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
|
2713
2323
|
expect(fs.readlinkSync(linkPath)).toBe("new-target.md");
|
|
2714
2324
|
expect(result.filesTombstoned).toBe(0);
|
|
2715
|
-
const journal =
|
|
2325
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2716
2326
|
expect(journal.files["docs/link.md"]).toBeDefined();
|
|
2717
2327
|
expect(journal.files["docs/link.md"].hash).toBe(baselineHash);
|
|
2718
2328
|
});
|
|
@@ -2779,7 +2389,7 @@ describe("sync", () => {
|
|
|
2779
2389
|
// The really-deleted file MUST be tombstoned.
|
|
2780
2390
|
expect(fs.existsSync(reallyDeletedPath)).toBe(false);
|
|
2781
2391
|
expect(result.filesTombstoned).toBe(1);
|
|
2782
|
-
const journal =
|
|
2392
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2783
2393
|
// Journal entry retained for the invisible key (so next sync at
|
|
2784
2394
|
// broader scope can re-evaluate).
|
|
2785
2395
|
expect(journal.files["docs/invisible-to-list.md"]).toBeDefined();
|
|
@@ -2853,7 +2463,7 @@ describe("sync", () => {
|
|
|
2853
2463
|
expect(result.filesDownloaded).toBe(0);
|
|
2854
2464
|
expect(result.filesTombstoned).toBeGreaterThanOrEqual(1);
|
|
2855
2465
|
// Journal entry dropped so it converges (no re-tombstone next run).
|
|
2856
|
-
const journal =
|
|
2466
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2857
2467
|
expect(journal.files["docs/shared.md"]).toBeUndefined();
|
|
2858
2468
|
});
|
|
2859
2469
|
it("delete-resync: a re-created object NEWER than the tombstone DOES sync (tombstone is not permanent)", async () => {
|
|
@@ -2945,7 +2555,7 @@ describe("sync", () => {
|
|
|
2945
2555
|
expect(result.conflicts).toBeGreaterThanOrEqual(1);
|
|
2946
2556
|
expect(result.conflictPaths).toContain("docs/edited.md");
|
|
2947
2557
|
expect(fs.existsSync(editedPath)).toBe(true);
|
|
2948
|
-
expect(fs.readFileSync(
|
|
2558
|
+
expect(fs.readFileSync(editedPath, "utf-8")).toBe("my local edits");
|
|
2949
2559
|
expect(result.filesTombstoned).toBe(0);
|
|
2950
2560
|
});
|
|
2951
2561
|
it("delete-resync: suppression is per-key and the fetch is company-scoped (no cross-tenant bleed)", async () => {
|
|
@@ -3017,7 +2627,7 @@ describe("sync", () => {
|
|
|
3017
2627
|
});
|
|
3018
2628
|
expect(fs.existsSync(deniedPath)).toBe(true);
|
|
3019
2629
|
expect(result.filesTombstoned).toBe(0);
|
|
3020
|
-
const journal =
|
|
2630
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3021
2631
|
expect(journal.files["docs/out-of-scope.md"]).toBeDefined();
|
|
3022
2632
|
});
|
|
3023
2633
|
it("retains the journal entry when tombstone unlink fails with EACCES (Codex P1 — Bug #9 follow-up)", async () => {
|
|
@@ -3065,7 +2675,7 @@ describe("sync", () => {
|
|
|
3065
2675
|
// Tombstone NOT counted — the delete wasn't honored.
|
|
3066
2676
|
expect(result.filesTombstoned).toBe(0);
|
|
3067
2677
|
// Journal entry MUST be retained so the next sync retries.
|
|
3068
|
-
const journal =
|
|
2678
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3069
2679
|
expect(journal.files["docs/locked-by-peer.md"]).toBeDefined();
|
|
3070
2680
|
expect(journal.files["docs/locked-by-peer.md"].hash).toBe("locked-hash");
|
|
3071
2681
|
}
|
|
@@ -3292,7 +2902,7 @@ describe("sync", () => {
|
|
|
3292
2902
|
expect(s3Module.downloadFile).not.toHaveBeenCalled();
|
|
3293
2903
|
expect(fs.existsSync(escapedPath)).toBe(false);
|
|
3294
2904
|
expect(fs.existsSync(path.join(companyRoot, traversalKey))).toBe(false);
|
|
3295
|
-
const journal =
|
|
2905
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3296
2906
|
expect(journal.files[traversalKey]).toBeUndefined();
|
|
3297
2907
|
}
|
|
3298
2908
|
finally {
|
|
@@ -3326,7 +2936,7 @@ describe("sync", () => {
|
|
|
3326
2936
|
expect(s3Module.downloadFile).not.toHaveBeenCalled();
|
|
3327
2937
|
expect(fs.existsSync(escapedPath)).toBe(false);
|
|
3328
2938
|
expect(fs.existsSync(path.join(companyRoot, remoteKey))).toBe(false);
|
|
3329
|
-
const journal =
|
|
2939
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3330
2940
|
expect(journal.files[remoteKey]).toBeUndefined();
|
|
3331
2941
|
}
|
|
3332
2942
|
finally {
|
|
@@ -3401,46 +3011,10 @@ describe("sync", () => {
|
|
|
3401
3011
|
vaultConfig: mockConfig,
|
|
3402
3012
|
hqRoot: tmpDir,
|
|
3403
3013
|
});
|
|
3404
|
-
const journal =
|
|
3014
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3405
3015
|
expect(journal.files["docs/handoff.md"].remoteEtag).toBe("abc123");
|
|
3406
3016
|
expect(journal.files["knowledge/readme.md"].remoteEtag).toBe("def456");
|
|
3407
3017
|
});
|
|
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
|
-
});
|
|
3444
3018
|
it("checkpoints completed downloads so a failed bulk pass resumes only its tail", async () => {
|
|
3445
3019
|
process.env.HQ_SYNC_TRANSFER_CONCURRENCY = "1";
|
|
3446
3020
|
const remoteFiles = Array.from({ length: 9 }, (_, index) => ({
|
|
@@ -3458,7 +3032,7 @@ describe("sync", () => {
|
|
|
3458
3032
|
if (key === "bulk/file-8.md") {
|
|
3459
3033
|
// This models the next transfer crashing the process. The first
|
|
3460
3034
|
// eight entries must already be durable before the final writer.
|
|
3461
|
-
const checkpoint =
|
|
3035
|
+
const checkpoint = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3462
3036
|
expect(Object.keys(checkpoint.files)).toHaveLength(8);
|
|
3463
3037
|
throw new Error("simulated crash after checkpoint");
|
|
3464
3038
|
}
|
|
@@ -3472,7 +3046,7 @@ describe("sync", () => {
|
|
|
3472
3046
|
onEvent: () => undefined,
|
|
3473
3047
|
});
|
|
3474
3048
|
expect(first.filesDownloaded).toBe(8);
|
|
3475
|
-
const checkpoint =
|
|
3049
|
+
const checkpoint = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3476
3050
|
expect(Object.keys(checkpoint.files)).toHaveLength(8);
|
|
3477
3051
|
vi.mocked(s3Module.downloadFile).mockImplementation(defaultDownload);
|
|
3478
3052
|
vi.mocked(s3Module.downloadFile).mockClear();
|
|
@@ -3923,7 +3497,7 @@ describe("sync", () => {
|
|
|
3923
3497
|
// skip-unchanged works on the next tick.
|
|
3924
3498
|
const journalMod = await import("../journal.js");
|
|
3925
3499
|
const expectedHash = journalMod.hashSymlinkTarget(linkTarget);
|
|
3926
|
-
const journal =
|
|
3500
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3927
3501
|
expect(journal.files[linkKey]).toMatchObject({
|
|
3928
3502
|
hash: expectedHash,
|
|
3929
3503
|
size: 0,
|
|
@@ -4255,7 +3829,7 @@ describe("sync", () => {
|
|
|
4255
3829
|
expect(errOutput).toContain("manual");
|
|
4256
3830
|
expect(errOutput).not.toContain("directory-overlay marker");
|
|
4257
3831
|
});
|
|
4258
|
-
it("shared-mode leg
|
|
3832
|
+
it("shared-mode leg surfaces the materialization gap and names the shared→all lever (feedback_d2082110)", async () => {
|
|
4259
3833
|
// Reporter feedback_d2082110 (cody@jonesroadbeauty.com): four memberships
|
|
4260
3834
|
// were configured as `shared` rather than `all`, so not all vault content
|
|
4261
3835
|
// materialized on the second device — a SILENT gap the reporter had to
|
|
@@ -4288,19 +3862,16 @@ describe("sync", () => {
|
|
|
4288
3862
|
onEvent: (e) => events.push(e),
|
|
4289
3863
|
});
|
|
4290
3864
|
// 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.
|
|
4298
3865
|
expect(result.filesDownloaded).toBe(1);
|
|
4299
|
-
expect(result.filesOutOfScope).toBe(
|
|
3866
|
+
expect(result.filesOutOfScope).toBe(1);
|
|
4300
3867
|
expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "knowledge", "present.md"))).toBe(true);
|
|
4301
3868
|
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.
|
|
4302
3870
|
const gap = events.find((e) => e.type === "scope-materialization-gap");
|
|
4303
|
-
expect(gap).
|
|
3871
|
+
expect(gap).toBeDefined();
|
|
3872
|
+
expect(gap.count).toBe(1);
|
|
3873
|
+
expect(gap.syncMode).toBe("shared");
|
|
3874
|
+
expect(gap.samplePaths).toEqual(["projects/secret.md"]);
|
|
4304
3875
|
});
|
|
4305
3876
|
it("all-mode leg (default) never emits a materialization gap — no scoping, no noise", async () => {
|
|
4306
3877
|
// Boundary for the feedback_d2082110 gap surface: in `all` mode every key is
|
|
@@ -4573,7 +4144,7 @@ describe("sync", () => {
|
|
|
4573
4144
|
// The journal MUST be updated to reflect the user's keep
|
|
4574
4145
|
// decision. Pre-fix: catch swallowed the ENOENT, journal stayed
|
|
4575
4146
|
// at oldHash + old etag, every subsequent sync re-fired.
|
|
4576
|
-
const journal =
|
|
4147
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
4577
4148
|
const entry = journal.files[linkKey];
|
|
4578
4149
|
expect(entry.hash).toBe(expectedNewHash);
|
|
4579
4150
|
expect(entry.size).toBe(0);
|
|
@@ -4872,7 +4443,7 @@ describe("scope-invalid key hardening (companies/ prefix in a company vault —
|
|
|
4872
4443
|
// Cleaned, not re-uploaded: local doubled-tree copy gone, journal dropped.
|
|
4873
4444
|
expect(fs.existsSync(path.join(doubled, "poison.md"))).toBe(false);
|
|
4874
4445
|
expect(result.filesTombstoned).toBeGreaterThanOrEqual(1);
|
|
4875
|
-
const journal =
|
|
4446
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
4876
4447
|
expect(journal.files["companies/acme/knowledge/poison.md"]).toBeUndefined();
|
|
4877
4448
|
// No HEAD verify against the invalid key — the server validator would
|
|
4878
4449
|
// reject it (INVALID_KEY_COMPANIES_SCOPED) and defer the cleanup forever.
|
|
@@ -5000,7 +4571,7 @@ describe("junk key-spelling guard (US-002 mixed-version amplifier)", () => {
|
|
|
5000
4571
|
expect(events.filter((e) => e.type === "error")).toHaveLength(0);
|
|
5001
4572
|
expect(result.aborted).toBe(false);
|
|
5002
4573
|
expect(result.filesSkipped).toBe(1);
|
|
5003
|
-
const journalAfter =
|
|
4574
|
+
const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
5004
4575
|
expect(Object.keys(journalAfter.files)).not.toContain(junkKey);
|
|
5005
4576
|
});
|
|
5006
4577
|
it("still pulls a junk-spelling key that is ALREADY journaled under that exact spelling", async () => {
|
|
@@ -5045,7 +4616,7 @@ describe("junk key-spelling guard (US-002 mixed-version amplifier)", () => {
|
|
|
5045
4616
|
// Junk twin still gated; the canonical journal entry tombstones out.
|
|
5046
4617
|
expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(1);
|
|
5047
4618
|
expect(result.filesTombstoned).toBe(1);
|
|
5048
|
-
const journalAfter =
|
|
4619
|
+
const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
5049
4620
|
expect(Object.keys(journalAfter.files)).not.toContain(canonicalKey);
|
|
5050
4621
|
});
|
|
5051
4622
|
it("always downloads a CANONICAL remote key even when the journal holds only its junk twin (post-doctor convergence)", async () => {
|
|
@@ -5062,7 +4633,7 @@ describe("junk key-spelling guard (US-002 mixed-version amplifier)", () => {
|
|
|
5062
4633
|
expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(1);
|
|
5063
4634
|
expect(vi.mocked(s3Module.downloadFile).mock.calls[0][1]).toBe(canonicalKey);
|
|
5064
4635
|
expect(result.filesDownloaded).toBe(1);
|
|
5065
|
-
const journalAfter =
|
|
4636
|
+
const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
5066
4637
|
expect(Object.keys(journalAfter.files)).toContain(canonicalKey);
|
|
5067
4638
|
});
|
|
5068
4639
|
it("does not tombstone a journaled canonical key when the LIST holds only its junk twin", async () => {
|
|
@@ -5078,7 +4649,7 @@ describe("junk key-spelling guard (US-002 mixed-version amplifier)", () => {
|
|
|
5078
4649
|
const { events, result } = await runSync();
|
|
5079
4650
|
// Junk twin skipped, canonical entry NOT tombstoned.
|
|
5080
4651
|
expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(1);
|
|
5081
|
-
const journalAfter =
|
|
4652
|
+
const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
5082
4653
|
expect(Object.keys(journalAfter.files)).toContain(canonicalKey);
|
|
5083
4654
|
expect(result.filesTombstoned ?? 0).toBe(0);
|
|
5084
4655
|
expect(events.filter((e) => e.type === "error")).toHaveLength(0);
|