@indigoai-us/hq-cloud 6.12.4 → 6.13.0
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.test.js +35 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +26 -0
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +100 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +100 -3
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +246 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +1 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js.map +1 -1
- package/dist/lib/cloud-authoritative.d.ts +45 -0
- package/dist/lib/cloud-authoritative.d.ts.map +1 -0
- package/dist/lib/cloud-authoritative.js +58 -0
- package/dist/lib/cloud-authoritative.js.map +1 -0
- package/dist/lib/cloud-authoritative.test.d.ts +2 -0
- package/dist/lib/cloud-authoritative.test.d.ts.map +1 -0
- package/dist/lib/cloud-authoritative.test.js +42 -0
- package/dist/lib/cloud-authoritative.test.js.map +1 -0
- package/dist/types.d.ts +11 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +41 -0
- package/src/cli/share.test.ts +119 -0
- package/src/cli/share.ts +32 -1
- package/src/cli/sync.test.ts +308 -0
- package/src/cli/sync.ts +111 -3
- package/src/journal.ts +1 -1
- package/src/lib/cloud-authoritative.test.ts +45 -0
- package/src/lib/cloud-authoritative.ts +59 -0
- package/src/types.ts +11 -1
package/src/cli/sync.test.ts
CHANGED
|
@@ -279,6 +279,76 @@ describe("sync", () => {
|
|
|
279
279
|
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("local version");
|
|
280
280
|
});
|
|
281
281
|
|
|
282
|
+
it("conflict keep marks the entry localDiverges (journal-honesty: kept copy never matched the recorded remote etag)", async () => {
|
|
283
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
284
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
285
|
+
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
|
|
286
|
+
fs.writeFileSync(
|
|
287
|
+
journalPath,
|
|
288
|
+
JSON.stringify({
|
|
289
|
+
version: "1",
|
|
290
|
+
lastSync: new Date().toISOString(),
|
|
291
|
+
files: {
|
|
292
|
+
"docs/handoff.md": {
|
|
293
|
+
hash: "old-hash-from-last-sync",
|
|
294
|
+
size: 20,
|
|
295
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
296
|
+
direction: "down",
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const result = await sync({
|
|
303
|
+
company: "acme",
|
|
304
|
+
onConflict: "keep",
|
|
305
|
+
vaultConfig: mockConfig,
|
|
306
|
+
hqRoot: tmpDir,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
expect(result.conflicts).toBe(1);
|
|
310
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
311
|
+
// remoteEtag still recorded so the conflict can't re-fire (#137 invariant)…
|
|
312
|
+
expect(journal.files["docs/handoff.md"].remoteEtag).toBeTruthy();
|
|
313
|
+
// …but the kept-local copy is flagged divergent so a later currency-gated
|
|
314
|
+
// delete cannot fire on a journal that lies about local==remote.
|
|
315
|
+
expect(journal.files["docs/handoff.md"].localDiverges).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("a genuine download clears a stale localDiverges flag (entry is now truly local==remote)", async () => {
|
|
319
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
320
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
321
|
+
fs.writeFileSync(
|
|
322
|
+
journalPath,
|
|
323
|
+
JSON.stringify({
|
|
324
|
+
version: "2",
|
|
325
|
+
lastSync: new Date().toISOString(),
|
|
326
|
+
files: {
|
|
327
|
+
"docs/handoff.md": {
|
|
328
|
+
hash: "old-divergent-hash",
|
|
329
|
+
size: 20,
|
|
330
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
331
|
+
direction: "down",
|
|
332
|
+
remoteEtag: "old-etag",
|
|
333
|
+
localDiverges: true,
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
pulls: [],
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
// Remote advanced to a new etag; local absent → genuine download lands the
|
|
340
|
+
// remote bytes, so local==remote now and the divergence flag must clear.
|
|
341
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
342
|
+
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"new-etag"' },
|
|
343
|
+
]);
|
|
344
|
+
|
|
345
|
+
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
346
|
+
|
|
347
|
+
expect(fs.existsSync(path.join(companyDocs, "handoff.md"))).toBe(true);
|
|
348
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
349
|
+
expect(journal.files["docs/handoff.md"]?.localDiverges).toBeFalsy();
|
|
350
|
+
});
|
|
351
|
+
|
|
282
352
|
it("emits a conflict event with path + resolution on hash mismatch", async () => {
|
|
283
353
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
284
354
|
fs.mkdirSync(companyDocs, { recursive: true });
|
|
@@ -451,6 +521,58 @@ describe("sync", () => {
|
|
|
451
521
|
expect(litter).toHaveLength(1);
|
|
452
522
|
});
|
|
453
523
|
|
|
524
|
+
it("cloud-authoritative file is PULL-WINS on conflict: overwrites local, no mirror, no false-stamp", async () => {
|
|
525
|
+
// company-brief.md is server-regenerated (gardener), so a divergence is not
|
|
526
|
+
// a real conflict — the cloud copy wins. Even under --on-conflict keep, the
|
|
527
|
+
// conflict path must short-circuit to a plain overwrite: NO `.conflict-`
|
|
528
|
+
// mirror (the loop) and NO journal stamp of the remote etag over divergent
|
|
529
|
+
// local content (Finding B). Contrast the docs/handoff.md test above, which
|
|
530
|
+
// (correctly) keeps local + mirrors for a client-owned file.
|
|
531
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
532
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
533
|
+
fs.writeFileSync(path.join(companyRoot, "company-brief.md"), "local richer brief");
|
|
534
|
+
|
|
535
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
536
|
+
{ key: "company-brief.md", size: 17, lastModified: new Date(), etag: '"newbrief"' },
|
|
537
|
+
]);
|
|
538
|
+
// Default downloadFile mock writes "mock file content" — the cloud copy.
|
|
539
|
+
|
|
540
|
+
fs.writeFileSync(
|
|
541
|
+
journalPath,
|
|
542
|
+
JSON.stringify({
|
|
543
|
+
version: "1",
|
|
544
|
+
lastSync: new Date().toISOString(),
|
|
545
|
+
files: {
|
|
546
|
+
"company-brief.md": {
|
|
547
|
+
hash: "stale-hash",
|
|
548
|
+
size: 18,
|
|
549
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
550
|
+
direction: "down",
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
}),
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
const result = await sync({
|
|
557
|
+
company: "acme",
|
|
558
|
+
onConflict: "keep",
|
|
559
|
+
vaultConfig: mockConfig,
|
|
560
|
+
hqRoot: tmpDir,
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// Cloud wins: not counted as a conflict; local overwritten with the cloud copy.
|
|
564
|
+
expect(result.conflicts).toBe(0);
|
|
565
|
+
expect(result.conflictPaths).toEqual([]);
|
|
566
|
+
expect(
|
|
567
|
+
fs.readFileSync(path.join(companyRoot, "company-brief.md"), "utf-8"),
|
|
568
|
+
).toBe("mock file content");
|
|
569
|
+
// No conflict mirror littered.
|
|
570
|
+
const litter = fs
|
|
571
|
+
.readdirSync(companyRoot)
|
|
572
|
+
.filter((f) => f.includes(".conflict-"));
|
|
573
|
+
expect(litter).toHaveLength(0);
|
|
574
|
+
});
|
|
575
|
+
|
|
454
576
|
it("pre-primes new-file GET presigns before per-file created-by HEADs (avoids the presign-burst breaker trip)", async () => {
|
|
455
577
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
456
578
|
{ key: "alpha.md", size: 10, lastModified: new Date(), etag: '"a1"' },
|
|
@@ -832,6 +954,192 @@ describe("sync", () => {
|
|
|
832
954
|
expect(journal.files[trackedKey]).toBeUndefined();
|
|
833
955
|
});
|
|
834
956
|
|
|
957
|
+
it("local journal tombstone suppresses drift-restore: a deleted-but-still-remote key is NOT re-downloaded (no respawn)", async () => {
|
|
958
|
+
// Repro of the intermittent delete-respawn: the user deleted a clean file,
|
|
959
|
+
// its delete has not (yet) cleared the S3 object, and the SERVER
|
|
960
|
+
// FILE_TOMBSTONE fetch is degraded (empty map — the logged failure mode).
|
|
961
|
+
// The only intent record is the LOCAL journal tombstone (`removedAt`).
|
|
962
|
+
// The drift-restore path must honor it and NOT re-download the key.
|
|
963
|
+
const key = "docs/handoff.md";
|
|
964
|
+
const localPath = path.join(tmpDir, "companies", "acme", key);
|
|
965
|
+
|
|
966
|
+
// Remote still lists the object, etag matching the journal, lastModified
|
|
967
|
+
// BEFORE the local delete — a stale resurrection of a deleted key, not a
|
|
968
|
+
// genuine remote re-create.
|
|
969
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
970
|
+
{
|
|
971
|
+
key,
|
|
972
|
+
size: 42,
|
|
973
|
+
lastModified: new Date(Date.now() - 2 * 86_400_000),
|
|
974
|
+
etag: '"abc123"',
|
|
975
|
+
},
|
|
976
|
+
]);
|
|
977
|
+
// Degraded-fetch case: server FILE_TOMBSTONE map is EMPTY.
|
|
978
|
+
setupFetchMock({ tombstones: [] });
|
|
979
|
+
|
|
980
|
+
// Local file ABSENT (user deleted it). Journal entry is a TOMBSTONE,
|
|
981
|
+
// recent enough to survive TTL GC.
|
|
982
|
+
fs.writeFileSync(
|
|
983
|
+
journalPath,
|
|
984
|
+
JSON.stringify({
|
|
985
|
+
version: "2",
|
|
986
|
+
lastSync: "2026-06-19T00:00:00.000Z",
|
|
987
|
+
files: {
|
|
988
|
+
[key]: {
|
|
989
|
+
hash: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
|
|
990
|
+
size: 42,
|
|
991
|
+
syncedAt: "2026-06-19T00:00:00.000Z",
|
|
992
|
+
direction: "down",
|
|
993
|
+
remoteEtag: "abc123",
|
|
994
|
+
removedAt: new Date().toISOString(),
|
|
995
|
+
removedReason: "local-delete",
|
|
996
|
+
},
|
|
997
|
+
},
|
|
998
|
+
pulls: [],
|
|
999
|
+
}),
|
|
1000
|
+
);
|
|
1001
|
+
|
|
1002
|
+
await sync({
|
|
1003
|
+
company: "acme",
|
|
1004
|
+
vaultConfig: mockConfig,
|
|
1005
|
+
hqRoot: tmpDir,
|
|
1006
|
+
onEvent: () => {},
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
// The intentionally-deleted key must NOT be re-materialised.
|
|
1010
|
+
expect(s3Module.downloadFile).not.toHaveBeenCalledWith(
|
|
1011
|
+
expect.anything(),
|
|
1012
|
+
key,
|
|
1013
|
+
expect.anything(),
|
|
1014
|
+
);
|
|
1015
|
+
expect(fs.existsSync(localPath)).toBe(false);
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
it("pull producer: a single clean/current/non-divergent missing file is tombstoned (intentional delete), not drift-restored", async () => {
|
|
1019
|
+
// The user deleted a genuinely-synced file. Its entry is clean (etag matches
|
|
1020
|
+
// the current remote — currency holds), not divergent. The pull leg must
|
|
1021
|
+
// treat the absence as an intentional delete: stamp a persisting
|
|
1022
|
+
// `local-delete` tombstone and SKIP the re-download — not resurrect it.
|
|
1023
|
+
const key = "docs/gone.md";
|
|
1024
|
+
const localPath = path.join(tmpDir, "companies", "acme", key);
|
|
1025
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1026
|
+
{ key, size: 42, lastModified: new Date(), etag: '"cur123"' },
|
|
1027
|
+
]);
|
|
1028
|
+
setupFetchMock({ tombstones: [] });
|
|
1029
|
+
fs.writeFileSync(
|
|
1030
|
+
journalPath,
|
|
1031
|
+
JSON.stringify({
|
|
1032
|
+
version: "2",
|
|
1033
|
+
lastSync: new Date().toISOString(),
|
|
1034
|
+
files: {
|
|
1035
|
+
[key]: {
|
|
1036
|
+
hash: "h",
|
|
1037
|
+
size: 42,
|
|
1038
|
+
syncedAt: new Date().toISOString(),
|
|
1039
|
+
direction: "down",
|
|
1040
|
+
remoteEtag: "cur123",
|
|
1041
|
+
},
|
|
1042
|
+
},
|
|
1043
|
+
pulls: [],
|
|
1044
|
+
}),
|
|
1045
|
+
);
|
|
1046
|
+
|
|
1047
|
+
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1048
|
+
|
|
1049
|
+
expect(s3Module.downloadFile).not.toHaveBeenCalledWith(
|
|
1050
|
+
expect.anything(),
|
|
1051
|
+
key,
|
|
1052
|
+
expect.anything(),
|
|
1053
|
+
);
|
|
1054
|
+
expect(fs.existsSync(localPath)).toBe(false);
|
|
1055
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1056
|
+
expect(journal.files[key]?.removedAt).toBeTruthy();
|
|
1057
|
+
expect(journal.files[key]?.removedReason).toBe("local-delete");
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
it("pull producer: a BULK disappearance is drift-restored, NOT tombstoned (ridge-incident guard in the pull leg)", async () => {
|
|
1061
|
+
// 12 clean files vanish at once (corrupt mirror / bulk rm / checkout). The
|
|
1062
|
+
// bulk-asymmetry guard refuses to treat this as intent — every file is
|
|
1063
|
+
// healed (re-downloaded), none tombstoned.
|
|
1064
|
+
const keys = Array.from({ length: 12 }, (_, i) => `docs/f${i}.md`);
|
|
1065
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(
|
|
1066
|
+
keys.map((k) => ({ key: k, size: 10, lastModified: new Date(), etag: `"e-${k}"` })),
|
|
1067
|
+
);
|
|
1068
|
+
setupFetchMock({ tombstones: [] });
|
|
1069
|
+
const files: Record<string, unknown> = {};
|
|
1070
|
+
for (const k of keys) {
|
|
1071
|
+
files[k] = {
|
|
1072
|
+
hash: "h",
|
|
1073
|
+
size: 10,
|
|
1074
|
+
syncedAt: new Date().toISOString(),
|
|
1075
|
+
direction: "down",
|
|
1076
|
+
remoteEtag: `e-${k}`,
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
fs.writeFileSync(
|
|
1080
|
+
journalPath,
|
|
1081
|
+
JSON.stringify({
|
|
1082
|
+
version: "2",
|
|
1083
|
+
lastSync: new Date().toISOString(),
|
|
1084
|
+
files,
|
|
1085
|
+
pulls: [],
|
|
1086
|
+
}),
|
|
1087
|
+
);
|
|
1088
|
+
|
|
1089
|
+
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1090
|
+
|
|
1091
|
+
// All restored, none tombstoned.
|
|
1092
|
+
expect(s3Module.downloadFile).toHaveBeenCalledTimes(12);
|
|
1093
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1094
|
+
for (const k of keys) {
|
|
1095
|
+
expect(journal.files[k]?.removedAt).toBeUndefined();
|
|
1096
|
+
}
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
it("pull producer: a local-delete tombstone persists across pulls (not consumed/removed), so the delete stays propagatable", async () => {
|
|
1100
|
+
// After the intentional-delete tombstone is stamped, a later pull must NOT
|
|
1101
|
+
// drop the entry (the push leg still needs it to propagate the S3 delete).
|
|
1102
|
+
// It stays skipped + preserved until the push propagates or TTL GC.
|
|
1103
|
+
const key = "docs/gone.md";
|
|
1104
|
+
const localPath = path.join(tmpDir, "companies", "acme", key);
|
|
1105
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1106
|
+
{ key, size: 42, lastModified: new Date(Date.now() - 2 * 86_400_000), etag: '"cur123"' },
|
|
1107
|
+
]);
|
|
1108
|
+
setupFetchMock({ tombstones: [] });
|
|
1109
|
+
fs.writeFileSync(
|
|
1110
|
+
journalPath,
|
|
1111
|
+
JSON.stringify({
|
|
1112
|
+
version: "2",
|
|
1113
|
+
lastSync: new Date().toISOString(),
|
|
1114
|
+
files: {
|
|
1115
|
+
[key]: {
|
|
1116
|
+
hash: "h",
|
|
1117
|
+
size: 42,
|
|
1118
|
+
syncedAt: new Date().toISOString(),
|
|
1119
|
+
direction: "down",
|
|
1120
|
+
remoteEtag: "cur123",
|
|
1121
|
+
removedAt: new Date().toISOString(),
|
|
1122
|
+
removedReason: "local-delete",
|
|
1123
|
+
},
|
|
1124
|
+
},
|
|
1125
|
+
pulls: [],
|
|
1126
|
+
}),
|
|
1127
|
+
);
|
|
1128
|
+
|
|
1129
|
+
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1130
|
+
|
|
1131
|
+
expect(s3Module.downloadFile).not.toHaveBeenCalledWith(
|
|
1132
|
+
expect.anything(),
|
|
1133
|
+
key,
|
|
1134
|
+
expect.anything(),
|
|
1135
|
+
);
|
|
1136
|
+
expect(fs.existsSync(localPath)).toBe(false);
|
|
1137
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1138
|
+
// Entry PRESERVED as a tombstone, not removed.
|
|
1139
|
+
expect(journal.files[key]?.removedAt).toBeTruthy();
|
|
1140
|
+
expect(journal.files[key]?.removedReason).toBe("local-delete");
|
|
1141
|
+
});
|
|
1142
|
+
|
|
835
1143
|
it("aborts on --on-conflict abort", async () => {
|
|
836
1144
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
837
1145
|
fs.mkdirSync(companyDocs, { recursive: true });
|
package/src/cli/sync.ts
CHANGED
|
@@ -34,6 +34,8 @@ import {
|
|
|
34
34
|
normalizeEtag,
|
|
35
35
|
migrateToV2,
|
|
36
36
|
gcTombstones,
|
|
37
|
+
isTombstone,
|
|
38
|
+
tombstoneEntry,
|
|
37
39
|
lastPullRecord,
|
|
38
40
|
appendPullRecord,
|
|
39
41
|
generatePullId,
|
|
@@ -69,6 +71,7 @@ import {
|
|
|
69
71
|
readShortMachineId,
|
|
70
72
|
} from "../lib/conflict-file.js";
|
|
71
73
|
import { appendConflictEntry } from "../lib/conflict-index.js";
|
|
74
|
+
import { isCloudAuthoritative } from "../lib/cloud-authoritative.js";
|
|
72
75
|
import { reindex } from "./reindex.js";
|
|
73
76
|
import { withOperationLock } from "../operation-lock.js";
|
|
74
77
|
import {
|
|
@@ -212,7 +215,7 @@ export type SyncProgressEvent =
|
|
|
212
215
|
path: string;
|
|
213
216
|
journalEtag: string;
|
|
214
217
|
remoteEtag: string;
|
|
215
|
-
reason: "stale-etag" | "legacy-no-etag" | "bulk-asymmetry";
|
|
218
|
+
reason: "stale-etag" | "legacy-no-etag" | "bulk-asymmetry" | "divergent-local";
|
|
216
219
|
}
|
|
217
220
|
| {
|
|
218
221
|
/**
|
|
@@ -1048,6 +1051,19 @@ async function executeConflictItem(
|
|
|
1048
1051
|
): Promise<SyncResult | null> {
|
|
1049
1052
|
const { remoteFile, localPath } = item;
|
|
1050
1053
|
|
|
1054
|
+
// Cloud-authoritative paths (server-regenerated: company-brief.md, board.json,
|
|
1055
|
+
// ontology/ signals/ sources/) are PULL-WINS: the cloud copy is the source of
|
|
1056
|
+
// truth, so a divergence here is not a real conflict. Resolve it as a normal
|
|
1057
|
+
// overwrite-from-cloud — queue a plain download (which stamps the journal with
|
|
1058
|
+
// the REMOTE hash/etag) and skip the conflict path entirely. This removes both
|
|
1059
|
+
// the `.conflict-` mirror loop AND the journal false-stamp (remote etag over
|
|
1060
|
+
// divergent local content) that the keep path produced for these files.
|
|
1061
|
+
if (isCloudAuthoritative(path.relative(run.hqRoot, localPath))) {
|
|
1062
|
+
downloadItems.push({ action: "download", remoteFile, localPath, isNew: false });
|
|
1063
|
+
run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
|
|
1064
|
+
return null;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1051
1067
|
await refreshRunContextIfExpiring(run);
|
|
1052
1068
|
|
|
1053
1069
|
const detectedAt = new Date().toISOString();
|
|
@@ -1195,6 +1211,14 @@ async function executeConflictItem(
|
|
|
1195
1211
|
remoteFile.etag,
|
|
1196
1212
|
item.localMtime.getTime(),
|
|
1197
1213
|
);
|
|
1214
|
+
// Journal-honesty: we recorded the remote etag so this conflict can't
|
|
1215
|
+
// re-fire (#137), but the KEPT local copy diverges from that remote — it
|
|
1216
|
+
// never matched it. Flag the entry so the currency-gated delete planner
|
|
1217
|
+
// refuses to propagate a delete for it (its currency would falsely match on
|
|
1218
|
+
// HEAD, and the delete would destroy the divergent remote version). Any
|
|
1219
|
+
// genuine future download clears the flag by replacing the entry.
|
|
1220
|
+
const keptEntry = getEntry(run.journal, remoteFile.key);
|
|
1221
|
+
if (keptEntry) keptEntry.localDiverges = true;
|
|
1198
1222
|
return null;
|
|
1199
1223
|
}
|
|
1200
1224
|
|
|
@@ -1724,6 +1748,15 @@ interface PullPlan {
|
|
|
1724
1748
|
* caller (`sync()`) is responsible for emitting the resulting plan event
|
|
1725
1749
|
* before iterating `items`.
|
|
1726
1750
|
*/
|
|
1751
|
+
// Pull-leg intentional-delete bulk-asymmetry breaker. Mirrors the push leg's
|
|
1752
|
+
// BULK_ASYMMETRY_* (share.ts): an abnormally large fraction of clean files
|
|
1753
|
+
// vanishing at once is a corrupt mirror / bulk op / unmount (the ridge-incident
|
|
1754
|
+
// class), not intent — so at/above the threshold the pull leg drift-restores
|
|
1755
|
+
// every candidate instead of tombstoning it. Below it, a missing clean file is
|
|
1756
|
+
// an intentional delete.
|
|
1757
|
+
const PULL_INTENTIONAL_DELETE_MIN_ABS = 10;
|
|
1758
|
+
const PULL_INTENTIONAL_DELETE_RATIO = 0.1;
|
|
1759
|
+
|
|
1727
1760
|
function computePullPlan(
|
|
1728
1761
|
remoteFiles: RemoteFile[],
|
|
1729
1762
|
journal: SyncJournal,
|
|
@@ -1744,6 +1777,12 @@ function computePullPlan(
|
|
|
1744
1777
|
fileTombstones: ReadonlyMap<string, CompanyTombstone> = new Map(),
|
|
1745
1778
|
): PullPlan {
|
|
1746
1779
|
const items: PullPlanItem[] = [];
|
|
1780
|
+
// Clean/current/non-divergent files missing locally — collected here, then
|
|
1781
|
+
// resolved post-loop by the bulk-asymmetry decision (tombstone vs. restore).
|
|
1782
|
+
const intentionalDeleteCandidates: Array<{
|
|
1783
|
+
remoteFile: RemoteFile;
|
|
1784
|
+
localPath: string;
|
|
1785
|
+
}> = [];
|
|
1747
1786
|
|
|
1748
1787
|
for (const remoteFile of remoteFiles) {
|
|
1749
1788
|
const localPath = resolveContainedVaultPath(companyRoot, remoteFile.key);
|
|
@@ -1837,6 +1876,23 @@ function computePullPlan(
|
|
|
1837
1876
|
const tombstoneSuppresses =
|
|
1838
1877
|
tombstone !== undefined &&
|
|
1839
1878
|
!isRemoteRecreateAfterTombstone(remoteFile, tombstone);
|
|
1879
|
+
// LOCAL journal tombstone — this machine's own intentional-delete record,
|
|
1880
|
+
// independent of the (best-effort, intermittently-degraded) server
|
|
1881
|
+
// FILE_TOMBSTONE fetch above. When a clean local delete is classified, the
|
|
1882
|
+
// push leg stamps `removedAt` on the journal entry; honoring it here makes
|
|
1883
|
+
// the delete-vs-drift-restore outcome DETERMINISTIC, closing the
|
|
1884
|
+
// intermittent respawn where a degraded tombstone fetch let the pull leg
|
|
1885
|
+
// re-materialize an intentionally-deleted file. Reuses the same re-create
|
|
1886
|
+
// guard (a remote object newer than the local delete is a genuine re-create
|
|
1887
|
+
// and still downloads). Consulted ONLY on the `!localExists` re-download
|
|
1888
|
+
// path below — never to delete a file that is present locally.
|
|
1889
|
+
const localTombstoneSuppresses =
|
|
1890
|
+
journalEntry !== undefined &&
|
|
1891
|
+
isTombstone(journalEntry) &&
|
|
1892
|
+
journalEntry.removedAt !== undefined &&
|
|
1893
|
+
!isRemoteRecreateAfterTombstone(remoteFile, {
|
|
1894
|
+
deletedAt: journalEntry.removedAt,
|
|
1895
|
+
});
|
|
1840
1896
|
|
|
1841
1897
|
// lstat (not existsSync/statSync) handles three cases the legacy
|
|
1842
1898
|
// checks got wrong for symlinks:
|
|
@@ -2060,15 +2116,67 @@ function computePullPlan(
|
|
|
2060
2116
|
// re-create": the classic resurrection ("remote present → I'm behind →
|
|
2061
2117
|
// download") the planner must NOT do. Route to `tombstone-delete` so the
|
|
2062
2118
|
// executor drops any stale journal entry and the key stays gone, instead of
|
|
2063
|
-
// pulling the deleted object back in.
|
|
2064
|
-
|
|
2119
|
+
// pulling the deleted object back in. `localTombstoneSuppresses` adds the
|
|
2120
|
+
// same protection driven by this machine's own journal tombstone, so an
|
|
2121
|
+
// intentional delete is not resurrected even when the server FILE_TOMBSTONE
|
|
2122
|
+
// fetch is degraded.
|
|
2123
|
+
if (tombstoneSuppresses || localTombstoneSuppresses) {
|
|
2124
|
+
// A persisting `local-delete` tombstone (stamped by the intentional-delete
|
|
2125
|
+
// producer below) must KEEP its journal entry — the push leg still needs
|
|
2126
|
+
// it to propagate the S3 delete. Skip the re-download without dropping the
|
|
2127
|
+
// entry. Other tombstone kinds (scope-shrink / FILE_TOMBSTONE) drop it.
|
|
2128
|
+
if (journalEntry?.removedReason === "local-delete") {
|
|
2129
|
+
continue;
|
|
2130
|
+
}
|
|
2065
2131
|
items.push({ action: "tombstone-delete", remoteFile, localPath });
|
|
2066
2132
|
continue;
|
|
2067
2133
|
}
|
|
2068
2134
|
|
|
2135
|
+
// Intentional-delete producer: a clean, CURRENT (etag matches), non-divergent
|
|
2136
|
+
// file that is MISSING locally is the user deleting a genuinely-synced file.
|
|
2137
|
+
// Collect it; the post-loop bulk-asymmetry decision tombstones it (intent) or
|
|
2138
|
+
// restores it (mass loss = accident). A stale-etag, divergent, tombstoned, or
|
|
2139
|
+
// never-journaled missing file falls through to the normal download below.
|
|
2140
|
+
if (
|
|
2141
|
+
!localExists &&
|
|
2142
|
+
journalEntry !== undefined &&
|
|
2143
|
+
!isTombstone(journalEntry) &&
|
|
2144
|
+
!journalEntry.localDiverges &&
|
|
2145
|
+
journalEntry.remoteEtag !== undefined &&
|
|
2146
|
+
normalizeEtag(remoteFile.etag) === journalEntry.remoteEtag
|
|
2147
|
+
) {
|
|
2148
|
+
intentionalDeleteCandidates.push({ remoteFile, localPath });
|
|
2149
|
+
continue;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2069
2152
|
items.push({ action: "download", remoteFile, localPath, isNew: !localExists });
|
|
2070
2153
|
}
|
|
2071
2154
|
|
|
2155
|
+
// ── Intentional-delete bulk-asymmetry decision (pull-leg producer) ─────────
|
|
2156
|
+
// Below the breaker → stamp a persisting `local-delete` tombstone (the
|
|
2157
|
+
// suppression above keeps it from being re-pulled; the push leg propagates the
|
|
2158
|
+
// S3 delete on its next pass). At/above → drift-restore EVERY candidate: a
|
|
2159
|
+
// mass disappearance (corrupt mirror / bulk rm / checkout / unmount) is
|
|
2160
|
+
// accidental loss, never intent (the ridge-incident guard).
|
|
2161
|
+
const totalJournalEntries = Object.keys(journal.files).length;
|
|
2162
|
+
const intentionalDeleteIsBulk =
|
|
2163
|
+
intentionalDeleteCandidates.length >= PULL_INTENTIONAL_DELETE_MIN_ABS &&
|
|
2164
|
+
totalJournalEntries > 0 &&
|
|
2165
|
+
intentionalDeleteCandidates.length / totalJournalEntries >=
|
|
2166
|
+
PULL_INTENTIONAL_DELETE_RATIO;
|
|
2167
|
+
for (const cand of intentionalDeleteCandidates) {
|
|
2168
|
+
if (intentionalDeleteIsBulk) {
|
|
2169
|
+
items.push({
|
|
2170
|
+
action: "download",
|
|
2171
|
+
remoteFile: cand.remoteFile,
|
|
2172
|
+
localPath: cand.localPath,
|
|
2173
|
+
isNew: true,
|
|
2174
|
+
});
|
|
2175
|
+
} else {
|
|
2176
|
+
tombstoneEntry(journal, cand.remoteFile.key, "local-delete");
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2072
2180
|
let filesToDownload = 0;
|
|
2073
2181
|
let bytesToDownload = 0;
|
|
2074
2182
|
let filesToSkip = 0;
|
package/src/journal.ts
CHANGED
|
@@ -537,7 +537,7 @@ export function appendPullRecord(
|
|
|
537
537
|
export function tombstoneEntry(
|
|
538
538
|
journal: SyncJournal,
|
|
539
539
|
relativePath: string,
|
|
540
|
-
reason: "scope_shrink" | "narrow_apply" | "manual",
|
|
540
|
+
reason: "scope_shrink" | "narrow_apply" | "manual" | "local-delete",
|
|
541
541
|
now: string = new Date().toISOString(),
|
|
542
542
|
): void {
|
|
543
543
|
const entry = journal.files[relativePath];
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { isCloudAuthoritative } from "./cloud-authoritative.js";
|
|
3
|
+
|
|
4
|
+
describe("isCloudAuthoritative", () => {
|
|
5
|
+
it("matches server-regenerated root files (both path forms)", () => {
|
|
6
|
+
for (const p of [
|
|
7
|
+
"companies/indigo/company-brief.md",
|
|
8
|
+
"company-brief.md",
|
|
9
|
+
"companies/acme/board.json",
|
|
10
|
+
"board.json",
|
|
11
|
+
]) {
|
|
12
|
+
expect(isCloudAuthoritative(p)).toBe(true);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("matches the pipeline-authored dirs", () => {
|
|
17
|
+
for (const p of [
|
|
18
|
+
"companies/indigo/ontology/entities/concept/grok.md",
|
|
19
|
+
"ontology/entities/person/x.md",
|
|
20
|
+
"companies/indigo/signals/slack-dm/action_item/a.md",
|
|
21
|
+
"signals/decision/b.md",
|
|
22
|
+
"companies/indigo/sources/slack/T1-2.md",
|
|
23
|
+
"sources/meetings/m.md",
|
|
24
|
+
]) {
|
|
25
|
+
expect(isCloudAuthoritative(p)).toBe(true);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("does NOT match client-owned files", () => {
|
|
30
|
+
for (const p of [
|
|
31
|
+
"companies/indigo/skills/work-mesh/SKILL.md",
|
|
32
|
+
"companies/indigo/projects/company-agents/release/index.html",
|
|
33
|
+
"companies/indigo/workers/hq-notify/emails/notice.template.html",
|
|
34
|
+
"companies/indigo/knowledge/readme.md",
|
|
35
|
+
"company-brief.md.conflict-2026-06-18T00-00-00Z-501bc8.md", // a mirror, not the brief
|
|
36
|
+
"docs/handoff.md",
|
|
37
|
+
]) {
|
|
38
|
+
expect(isCloudAuthoritative(p)).toBe(false);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("normalizes windows separators", () => {
|
|
43
|
+
expect(isCloudAuthoritative("companies\\indigo\\ontology\\entities\\x.md")).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud-authoritative vault paths — files the SERVER regenerates and owns, so
|
|
3
|
+
* on a sync conflict the cloud version always wins (pull-wins) and NO
|
|
4
|
+
* `.conflict-` mirror is minted.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists
|
|
7
|
+
* ---------------
|
|
8
|
+
* Some vault files are written/regenerated server-side on a schedule, not by
|
|
9
|
+
* the client:
|
|
10
|
+
* - `company-brief.md` — rewritten by the ontology gardener's brief-generator
|
|
11
|
+
* on every run.
|
|
12
|
+
* - `board.json` — rewritten by board automation.
|
|
13
|
+
* - `ontology/**`, `signals/**`, `sources/**` — the gardener / sources+signals
|
|
14
|
+
* pipeline are the sole authors; clients only consume.
|
|
15
|
+
*
|
|
16
|
+
* A client that also holds these locally diverges from the server copy on
|
|
17
|
+
* essentially every sync. Under `--on-conflict keep` (what outposts run) that
|
|
18
|
+
* produced two compounding bugs:
|
|
19
|
+
* 1. CONFLICT LOOP — each sync minted a fresh `<file>.conflict-<ts>-<machine>`
|
|
20
|
+
* mirror that was never cleaned up (e.g. ~50 `company-brief.md.conflict-*`
|
|
21
|
+
* piled up on an outpost over a few days).
|
|
22
|
+
* 2. SILENT DRIFT — the keep path stamped the journal with the REMOTE etag
|
|
23
|
+
* while leaving the divergent LOCAL content in place, so the journal
|
|
24
|
+
* reported "in sync" forever and the file never reconciled.
|
|
25
|
+
*
|
|
26
|
+
* Treating these paths as cloud-authoritative resolves both: the conflict path
|
|
27
|
+
* short-circuits to a normal overwrite-from-cloud (correct journal stamp, no
|
|
28
|
+
* mirror), because for a server-owned file the cloud copy is the truth.
|
|
29
|
+
*
|
|
30
|
+
* Extending
|
|
31
|
+
* ---------
|
|
32
|
+
* Add a pattern to {@link CLOUD_AUTHORITATIVE_PATTERNS}. Patterns are matched
|
|
33
|
+
* against the VAULT-RELATIVE path (a leading `companies/<slug>/` is stripped
|
|
34
|
+
* first), so both call sites — the pull leg (hqRoot-relative
|
|
35
|
+
* `companies/<slug>/…`) and the push leg (company-relative `…`) — work.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** Vault-relative patterns for server-owned, cloud-wins files. */
|
|
39
|
+
export const CLOUD_AUTHORITATIVE_PATTERNS: readonly RegExp[] = [
|
|
40
|
+
/^company-brief\.md$/,
|
|
41
|
+
/^board\.json$/,
|
|
42
|
+
/^ontology\//,
|
|
43
|
+
/^signals\//,
|
|
44
|
+
/^sources\//,
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* True iff `relPath` is a server-regenerated, cloud-authoritative vault file.
|
|
49
|
+
* Accepts either an hqRoot-relative path (`companies/<slug>/…`) or a
|
|
50
|
+
* vault-relative path (`…`); Windows separators are normalized.
|
|
51
|
+
*/
|
|
52
|
+
export function isCloudAuthoritative(relPath: string): boolean {
|
|
53
|
+
const vaultRel = relPath
|
|
54
|
+
.split("\\")
|
|
55
|
+
.join("/")
|
|
56
|
+
.replace(/^\.?\//, "")
|
|
57
|
+
.replace(/^companies\/[^/]+\//, "");
|
|
58
|
+
return CLOUD_AUTHORITATIVE_PATTERNS.some((re) => re.test(vaultRel));
|
|
59
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -71,7 +71,7 @@ export interface JournalEntry {
|
|
|
71
71
|
* the same paths as orphans, then garbage-collected.
|
|
72
72
|
*/
|
|
73
73
|
removedAt?: string;
|
|
74
|
-
removedReason?: "scope_shrink" | "narrow_apply" | "manual";
|
|
74
|
+
removedReason?: "scope_shrink" | "narrow_apply" | "manual" | "local-delete";
|
|
75
75
|
/**
|
|
76
76
|
* Durable automatic-pull retention marker. Set when a scope shrink keeps an
|
|
77
77
|
* out-of-scope caller-authored or unknown-author entry on disk instead of
|
|
@@ -80,6 +80,16 @@ export interface JournalEntry {
|
|
|
80
80
|
* the entry becomes in-scope again.
|
|
81
81
|
*/
|
|
82
82
|
outOfScopeProtected?: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Journal-honesty marker. Set when a conflict was resolved by KEEPing a local
|
|
85
|
+
* copy that diverges from the remote: the entry still records the remote
|
|
86
|
+
* `remoteEtag` (so the conflict can't re-fire — the #137 invariant), but the
|
|
87
|
+
* local bytes never matched that remote. The currency-gated delete planner
|
|
88
|
+
* refuses to propagate a delete for such an entry — its currency would
|
|
89
|
+
* falsely "match" on HEAD, and deleting would destroy the divergent remote
|
|
90
|
+
* version. Cleared by any genuine download (which makes local == remote).
|
|
91
|
+
*/
|
|
92
|
+
localDiverges?: boolean;
|
|
83
93
|
}
|
|
84
94
|
|
|
85
95
|
/**
|