@indigoai-us/hq-cloud 6.14.9 → 6.14.10
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/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +89 -14
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +105 -3
- 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 +128 -41
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +241 -25
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +7 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +22 -1
- package/dist/journal.js.map +1 -1
- package/dist/personal-vault.d.ts +6 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +12 -0
- package/dist/personal-vault.js.map +1 -1
- package/dist/s3.d.ts +3 -1
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +3 -1
- package/dist/s3.js.map +1 -1
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.test.ts +129 -3
- package/src/cli/share.ts +114 -15
- package/src/cli/sync.test.ts +293 -24
- package/src/cli/sync.ts +173 -50
- package/src/journal.ts +28 -0
- package/src/personal-vault.ts +15 -0
- package/src/s3.ts +3 -0
- package/src/types.ts +13 -0
package/src/cli/sync.ts
CHANGED
|
@@ -221,7 +221,13 @@ export type SyncProgressEvent =
|
|
|
221
221
|
path: string;
|
|
222
222
|
journalEtag: string;
|
|
223
223
|
remoteEtag: string;
|
|
224
|
-
reason:
|
|
224
|
+
reason:
|
|
225
|
+
| "stale-etag"
|
|
226
|
+
| "legacy-no-etag"
|
|
227
|
+
| "bulk-asymmetry"
|
|
228
|
+
| "divergent-local"
|
|
229
|
+
| "missing-delete-intent"
|
|
230
|
+
| "intent-changed";
|
|
225
231
|
}
|
|
226
232
|
| {
|
|
227
233
|
/**
|
|
@@ -1202,11 +1208,19 @@ function executeFileTombstoneDelete(
|
|
|
1202
1208
|
counters: PullCounters,
|
|
1203
1209
|
): void {
|
|
1204
1210
|
const tombstoneKey = item.remoteFile.key;
|
|
1205
|
-
const tombstonePath =
|
|
1206
|
-
if (
|
|
1211
|
+
const tombstonePath = item.localPath;
|
|
1212
|
+
if (!isDownloadWritePathStillContained(run.companyRoot, tombstoneKey, tombstonePath)) {
|
|
1213
|
+
run.emit({
|
|
1214
|
+
type: "error",
|
|
1215
|
+
path: tombstoneKey,
|
|
1216
|
+
message: "tombstone-suppress unlink skipped: local parent escaped the sync root",
|
|
1217
|
+
});
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1207
1220
|
try {
|
|
1208
1221
|
const lstat = fs.lstatSync(tombstonePath);
|
|
1209
|
-
if (
|
|
1222
|
+
if (!matchesLocalSnapshot(tombstonePath, item.localSnapshot)) {
|
|
1223
|
+
reportLocalSnapshotConflict(run, counters, tombstoneKey);
|
|
1210
1224
|
return;
|
|
1211
1225
|
}
|
|
1212
1226
|
if (lstat.isSymbolicLink() || lstat.isFile()) {
|
|
@@ -1252,7 +1266,13 @@ async function executeConflictItem(
|
|
|
1252
1266
|
// the `.conflict-` mirror loop AND the journal false-stamp (remote etag over
|
|
1253
1267
|
// divergent local content) that the keep path produced for these files.
|
|
1254
1268
|
if (isCloudAuthoritative(vaultKeyForLocalPath(run.hqRoot, localPath))) {
|
|
1255
|
-
downloadItems.push({
|
|
1269
|
+
downloadItems.push({
|
|
1270
|
+
action: "download",
|
|
1271
|
+
remoteFile,
|
|
1272
|
+
localPath,
|
|
1273
|
+
isNew: false,
|
|
1274
|
+
localSnapshot: item.localSnapshot,
|
|
1275
|
+
});
|
|
1256
1276
|
run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
|
|
1257
1277
|
return null;
|
|
1258
1278
|
}
|
|
@@ -1315,6 +1335,7 @@ async function executeConflictItem(
|
|
|
1315
1335
|
"down",
|
|
1316
1336
|
remoteFile.etag,
|
|
1317
1337
|
item.localMtime.getTime(),
|
|
1338
|
+
item.localSnapshot.kind === "symlink" ? "symlink" : "file",
|
|
1318
1339
|
);
|
|
1319
1340
|
run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
|
|
1320
1341
|
counters.filesSkipped++;
|
|
@@ -1404,6 +1425,7 @@ async function executeConflictItem(
|
|
|
1404
1425
|
"down",
|
|
1405
1426
|
remoteFile.etag,
|
|
1406
1427
|
item.localMtime.getTime(),
|
|
1428
|
+
item.localSnapshot.kind === "symlink" ? "symlink" : "file",
|
|
1407
1429
|
);
|
|
1408
1430
|
// Journal-honesty: we recorded the remote etag so this conflict can't
|
|
1409
1431
|
// re-fire (#137), but the KEPT local copy diverges from that remote — it
|
|
@@ -1421,6 +1443,7 @@ async function executeConflictItem(
|
|
|
1421
1443
|
remoteFile,
|
|
1422
1444
|
localPath,
|
|
1423
1445
|
isNew: false,
|
|
1446
|
+
localSnapshot: item.localSnapshot,
|
|
1424
1447
|
});
|
|
1425
1448
|
return null;
|
|
1426
1449
|
}
|
|
@@ -1494,6 +1517,13 @@ async function downloadOne(
|
|
|
1494
1517
|
run.ctx,
|
|
1495
1518
|
remoteFile.key,
|
|
1496
1519
|
localPath,
|
|
1520
|
+
{
|
|
1521
|
+
beforeReplace: () => {
|
|
1522
|
+
if (!matchesLocalSnapshot(localPath, downloadItem.localSnapshot)) {
|
|
1523
|
+
throw new LocalSnapshotChangedError(remoteFile.key);
|
|
1524
|
+
}
|
|
1525
|
+
},
|
|
1526
|
+
},
|
|
1497
1527
|
);
|
|
1498
1528
|
reportModeWarnings(run, modeWarnings);
|
|
1499
1529
|
const author = metadata?.["created-by"] ?? null;
|
|
@@ -1515,6 +1545,7 @@ async function downloadOne(
|
|
|
1515
1545
|
remoteFile.etag,
|
|
1516
1546
|
localLstat.mtimeMs,
|
|
1517
1547
|
createdBySub,
|
|
1548
|
+
isLocalSymlink ? "symlink" : "file",
|
|
1518
1549
|
);
|
|
1519
1550
|
|
|
1520
1551
|
const priorEntry = getEntry(run.journal, remoteFile.key);
|
|
@@ -1531,7 +1562,9 @@ async function downloadOne(
|
|
|
1531
1562
|
counters.bytesDownloaded += size;
|
|
1532
1563
|
counters.changedPaths.push(remoteFile.key);
|
|
1533
1564
|
} catch (err) {
|
|
1534
|
-
if (
|
|
1565
|
+
if (err instanceof LocalSnapshotChangedError) {
|
|
1566
|
+
reportLocalSnapshotConflict(run, counters, remoteFile.key);
|
|
1567
|
+
} else if (isAccessDenied(err)) {
|
|
1535
1568
|
counters.filesSkipped++;
|
|
1536
1569
|
} else {
|
|
1537
1570
|
run.emit({
|
|
@@ -1622,13 +1655,13 @@ async function verifyPlannedJournalTombstones(
|
|
|
1622
1655
|
// never legitimately exist in a company vault, so treating it as verified
|
|
1623
1656
|
// is safe: the executor cleans the local copy + journal entry instead of
|
|
1624
1657
|
// the push leg ever re-uploading it (tombstone-not-reupload).
|
|
1625
|
-
const autoVerified:
|
|
1626
|
-
const toHeadVerify:
|
|
1627
|
-
for (const
|
|
1628
|
-
if (run.options.personalMode !== true && key.startsWith("companies/")) {
|
|
1629
|
-
autoVerified.push(
|
|
1658
|
+
const autoVerified: PullPlan["tombstones"] = [];
|
|
1659
|
+
const toHeadVerify: PullPlan["tombstones"] = [];
|
|
1660
|
+
for (const tombstone of plan.tombstones) {
|
|
1661
|
+
if (run.options.personalMode !== true && tombstone.key.startsWith("companies/")) {
|
|
1662
|
+
autoVerified.push(tombstone);
|
|
1630
1663
|
} else {
|
|
1631
|
-
toHeadVerify.push(
|
|
1664
|
+
toHeadVerify.push(tombstone);
|
|
1632
1665
|
}
|
|
1633
1666
|
}
|
|
1634
1667
|
plan.tombstones = toHeadVerify;
|
|
@@ -1637,22 +1670,22 @@ async function verifyPlannedJournalTombstones(
|
|
|
1637
1670
|
return;
|
|
1638
1671
|
}
|
|
1639
1672
|
|
|
1640
|
-
await primeObjectTransport(run.ctx, "get", plan.tombstones);
|
|
1673
|
+
await primeObjectTransport(run.ctx, "get", plan.tombstones.map((t) => t.key));
|
|
1641
1674
|
|
|
1642
1675
|
const HEAD_VERIFY_CONCURRENCY = 5;
|
|
1643
|
-
const verified:
|
|
1676
|
+
const verified: PullPlan["tombstones"] = [];
|
|
1644
1677
|
for (let i = 0; i < plan.tombstones.length; i += HEAD_VERIFY_CONCURRENCY) {
|
|
1645
1678
|
const batch = plan.tombstones.slice(i, i + HEAD_VERIFY_CONCURRENCY);
|
|
1646
1679
|
const results = await Promise.all(
|
|
1647
|
-
batch.map(async (
|
|
1680
|
+
batch.map(async (tombstone) => {
|
|
1648
1681
|
try {
|
|
1649
|
-
const head = await headRemoteFile(run.ctx, key);
|
|
1650
|
-
return head === null ?
|
|
1682
|
+
const head = await headRemoteFile(run.ctx, tombstone.key);
|
|
1683
|
+
return head === null ? tombstone : null;
|
|
1651
1684
|
} catch (err) {
|
|
1652
1685
|
if (isAccessDenied(err)) return null;
|
|
1653
1686
|
run.emit({
|
|
1654
1687
|
type: "error",
|
|
1655
|
-
path: key,
|
|
1688
|
+
path: tombstone.key,
|
|
1656
1689
|
message: `tombstone HEAD verify failed (deferring): ${
|
|
1657
1690
|
err instanceof Error ? err.message : String(err)
|
|
1658
1691
|
}`,
|
|
@@ -1661,8 +1694,8 @@ async function verifyPlannedJournalTombstones(
|
|
|
1661
1694
|
}
|
|
1662
1695
|
}),
|
|
1663
1696
|
);
|
|
1664
|
-
for (const
|
|
1665
|
-
if (
|
|
1697
|
+
for (const tombstone of results) {
|
|
1698
|
+
if (tombstone !== null) verified.push(tombstone);
|
|
1666
1699
|
}
|
|
1667
1700
|
}
|
|
1668
1701
|
plan.tombstones = [...autoVerified, ...verified];
|
|
@@ -1673,13 +1706,21 @@ function executeJournalTombstoneDeletes(
|
|
|
1673
1706
|
plan: PullPlan,
|
|
1674
1707
|
counters: PullCounters,
|
|
1675
1708
|
): void {
|
|
1676
|
-
for (const
|
|
1677
|
-
const localPath =
|
|
1678
|
-
if (
|
|
1709
|
+
for (const tombstone of plan.tombstones) {
|
|
1710
|
+
const { key, localPath, localSnapshot } = tombstone;
|
|
1711
|
+
if (!isDownloadWritePathStillContained(run.companyRoot, key, localPath)) {
|
|
1712
|
+
run.emit({
|
|
1713
|
+
type: "error",
|
|
1714
|
+
path: key,
|
|
1715
|
+
message: "tombstone unlink skipped: local parent escaped the sync root",
|
|
1716
|
+
});
|
|
1717
|
+
continue;
|
|
1718
|
+
}
|
|
1679
1719
|
let removedSomething = false;
|
|
1680
1720
|
try {
|
|
1681
1721
|
const lstat = fs.lstatSync(localPath);
|
|
1682
|
-
if (
|
|
1722
|
+
if (!matchesLocalSnapshot(localPath, localSnapshot)) {
|
|
1723
|
+
reportLocalSnapshotConflict(run, counters, key);
|
|
1683
1724
|
continue;
|
|
1684
1725
|
}
|
|
1685
1726
|
if (lstat.isSymbolicLink() || lstat.isFile()) {
|
|
@@ -1880,29 +1921,64 @@ function isDownloadWritePathStillContained(
|
|
|
1880
1921
|
return resolved !== null && path.resolve(resolved) === path.resolve(localPath);
|
|
1881
1922
|
}
|
|
1882
1923
|
|
|
1883
|
-
function
|
|
1884
|
-
journal: SyncJournal,
|
|
1885
|
-
key: string,
|
|
1886
|
-
localPath: string,
|
|
1887
|
-
lstat: fs.Stats,
|
|
1888
|
-
): boolean {
|
|
1889
|
-
const journalEntry = journal.files[key];
|
|
1890
|
-
if (!journalEntry?.hash) {
|
|
1891
|
-
return lstat.isSymbolicLink() || lstat.isFile();
|
|
1892
|
-
}
|
|
1893
|
-
|
|
1924
|
+
function snapshotLocalState(localPath: string): LocalSnapshot | null {
|
|
1894
1925
|
try {
|
|
1926
|
+
const lstat = fs.lstatSync(localPath);
|
|
1895
1927
|
if (lstat.isSymbolicLink()) {
|
|
1896
|
-
return hashSymlinkTarget(fs.readlinkSync(localPath))
|
|
1928
|
+
return { kind: "symlink", hash: hashSymlinkTarget(fs.readlinkSync(localPath)) };
|
|
1897
1929
|
}
|
|
1898
1930
|
if (lstat.isFile()) {
|
|
1899
|
-
return hashFile(localPath)
|
|
1931
|
+
return { kind: "file", hash: hashFile(localPath) };
|
|
1900
1932
|
}
|
|
1901
|
-
|
|
1902
|
-
return
|
|
1933
|
+
if (lstat.isDirectory()) return { kind: "directory" };
|
|
1934
|
+
return { kind: "other" };
|
|
1935
|
+
} catch (err: unknown) {
|
|
1936
|
+
const code =
|
|
1937
|
+
err && typeof err === "object" && "code" in err
|
|
1938
|
+
? (err as { code?: string }).code
|
|
1939
|
+
: undefined;
|
|
1940
|
+
return code === "ENOENT" ? { kind: "absent" } : null;
|
|
1903
1941
|
}
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
function matchesLocalSnapshot(localPath: string, expected: LocalSnapshot): boolean {
|
|
1945
|
+
const actual = snapshotLocalState(localPath);
|
|
1946
|
+
if (actual === null || actual.kind !== expected.kind) return false;
|
|
1947
|
+
return (
|
|
1948
|
+
(actual.kind !== "file" && actual.kind !== "symlink") ||
|
|
1949
|
+
actual.hash === (expected as Extract<LocalSnapshot, { hash: string }>).hash
|
|
1950
|
+
);
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
function hasCurrentLocalDeleteIntent(
|
|
1954
|
+
entry: SyncJournal["files"][string] | undefined,
|
|
1955
|
+
): boolean {
|
|
1956
|
+
const intent = entry?.localDeleteIntent;
|
|
1957
|
+
return !!(
|
|
1958
|
+
intent &&
|
|
1959
|
+
intent.version === 1 &&
|
|
1960
|
+
entry.remoteEtag &&
|
|
1961
|
+
entry.kind &&
|
|
1962
|
+
intent.remoteEtag === entry.remoteEtag &&
|
|
1963
|
+
intent.localHash === entry.hash &&
|
|
1964
|
+
intent.localKind === entry.kind
|
|
1965
|
+
);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
function reportLocalSnapshotConflict(
|
|
1969
|
+
run: PullRunContext,
|
|
1970
|
+
counters: PullCounters,
|
|
1971
|
+
key: string,
|
|
1972
|
+
): void {
|
|
1973
|
+
counters.conflicts++;
|
|
1974
|
+
counters.conflictPaths.push(key);
|
|
1975
|
+
run.emit({ type: "conflict", path: key, direction: "pull", resolution: "skip" });
|
|
1976
|
+
}
|
|
1904
1977
|
|
|
1905
|
-
|
|
1978
|
+
class LocalSnapshotChangedError extends Error {
|
|
1979
|
+
constructor(key: string) {
|
|
1980
|
+
super(`local state changed while downloading ${key}; replanning required`);
|
|
1981
|
+
}
|
|
1906
1982
|
}
|
|
1907
1983
|
|
|
1908
1984
|
/**
|
|
@@ -1911,8 +1987,18 @@ function tombstoneTargetDiverged(
|
|
|
1911
1987
|
* decide what to do. `localHash` is carried on `conflict` items so the
|
|
1912
1988
|
* executor can hand it to `resolveConflict` without re-hashing.
|
|
1913
1989
|
*/
|
|
1990
|
+
type LocalSnapshot =
|
|
1991
|
+
| { kind: "absent" | "directory" | "other" }
|
|
1992
|
+
| { kind: "file" | "symlink"; hash: string };
|
|
1993
|
+
|
|
1914
1994
|
type PullPlanItem =
|
|
1915
|
-
| {
|
|
1995
|
+
| {
|
|
1996
|
+
action: "download";
|
|
1997
|
+
remoteFile: RemoteFile;
|
|
1998
|
+
localPath: string;
|
|
1999
|
+
isNew: boolean;
|
|
2000
|
+
localSnapshot: LocalSnapshot;
|
|
2001
|
+
}
|
|
1916
2002
|
| { action: "skip-ignored"; remoteFile: RemoteFile; localPath: string }
|
|
1917
2003
|
| { action: "skip-personal-mode"; remoteFile: RemoteFile; localPath: string }
|
|
1918
2004
|
| { action: "skip-unchanged"; remoteFile: RemoteFile; localPath: string }
|
|
@@ -1941,7 +2027,12 @@ type PullPlanItem =
|
|
|
1941
2027
|
// (keys ABSENT from the LIST, HEAD-verified before delete), these are
|
|
1942
2028
|
// suppressed purely on the FILE_TOMBSTONE authority and skip HEAD-verify (the
|
|
1943
2029
|
// remote object is present by definition).
|
|
1944
|
-
| {
|
|
2030
|
+
| {
|
|
2031
|
+
action: "tombstone-delete";
|
|
2032
|
+
remoteFile: RemoteFile;
|
|
2033
|
+
localPath: string;
|
|
2034
|
+
localSnapshot: LocalSnapshot;
|
|
2035
|
+
}
|
|
1945
2036
|
| {
|
|
1946
2037
|
action: "conflict";
|
|
1947
2038
|
remoteFile: RemoteFile;
|
|
@@ -1963,6 +2054,7 @@ type PullPlanItem =
|
|
|
1963
2054
|
// and silently fail to stamp the journal — leaving the
|
|
1964
2055
|
// conflict to re-fire forever.
|
|
1965
2056
|
localSize: number;
|
|
2057
|
+
localSnapshot: LocalSnapshot;
|
|
1966
2058
|
};
|
|
1967
2059
|
|
|
1968
2060
|
interface PullPlan {
|
|
@@ -1984,7 +2076,11 @@ interface PullPlan {
|
|
|
1984
2076
|
* propagating the peer's push-side delete cross-machine (Bug #9).
|
|
1985
2077
|
* Carried on the plan so the executor can iterate without re-walking.
|
|
1986
2078
|
*/
|
|
1987
|
-
tombstones:
|
|
2079
|
+
tombstones: Array<{
|
|
2080
|
+
key: string;
|
|
2081
|
+
localPath: string;
|
|
2082
|
+
localSnapshot: LocalSnapshot;
|
|
2083
|
+
}>;
|
|
1988
2084
|
/**
|
|
1989
2085
|
* Count of `tombstone-delete` items — remote keys present in the LIST but
|
|
1990
2086
|
* suppressed by a FILE_TOMBSTONE (delete-resync). Surfaced on the plan event's
|
|
@@ -2221,6 +2317,7 @@ function computePullPlan(
|
|
|
2221
2317
|
const localTombstoneSuppresses =
|
|
2222
2318
|
journalEntry !== undefined &&
|
|
2223
2319
|
isTombstone(journalEntry) &&
|
|
2320
|
+
hasCurrentLocalDeleteIntent(journalEntry) &&
|
|
2224
2321
|
journalEntry.removedAt !== undefined &&
|
|
2225
2322
|
!isRemoteRecreateAfterTombstone(remoteFile, {
|
|
2226
2323
|
deletedAt: journalEntry.removedAt,
|
|
@@ -2239,6 +2336,7 @@ function computePullPlan(
|
|
|
2239
2336
|
// dangling link on every run.
|
|
2240
2337
|
// 3. A regular file: indistinguishable from current behaviour.
|
|
2241
2338
|
let localLstat: fs.Stats | null = null;
|
|
2339
|
+
let plannedLocalSnapshot: LocalSnapshot = { kind: "absent" };
|
|
2242
2340
|
let localPathBlockedByFileAncestor = false;
|
|
2243
2341
|
try {
|
|
2244
2342
|
localLstat = fs.lstatSync(localPath);
|
|
@@ -2345,6 +2443,10 @@ function computePullPlan(
|
|
|
2345
2443
|
: hashFile(localPath);
|
|
2346
2444
|
}
|
|
2347
2445
|
const localChanged = !!journalEntry && journalEntry.hash !== localHash;
|
|
2446
|
+
plannedLocalSnapshot = {
|
|
2447
|
+
kind: isLocalSymlink ? "symlink" : "file",
|
|
2448
|
+
hash: localHash,
|
|
2449
|
+
};
|
|
2348
2450
|
const remoteChanged =
|
|
2349
2451
|
!!journalEntry && hasRemoteChanged(remoteFile, journalEntry);
|
|
2350
2452
|
|
|
@@ -2367,9 +2469,15 @@ function computePullPlan(
|
|
|
2367
2469
|
localHash,
|
|
2368
2470
|
localMtime: localLstat!.mtime,
|
|
2369
2471
|
localSize: isLocalSymlink ? 0 : localLstat!.size,
|
|
2472
|
+
localSnapshot: plannedLocalSnapshot,
|
|
2370
2473
|
});
|
|
2371
2474
|
} else {
|
|
2372
|
-
items.push({
|
|
2475
|
+
items.push({
|
|
2476
|
+
action: "tombstone-delete",
|
|
2477
|
+
remoteFile,
|
|
2478
|
+
localPath,
|
|
2479
|
+
localSnapshot: plannedLocalSnapshot,
|
|
2480
|
+
});
|
|
2373
2481
|
}
|
|
2374
2482
|
continue;
|
|
2375
2483
|
}
|
|
@@ -2395,6 +2503,7 @@ function computePullPlan(
|
|
|
2395
2503
|
// every sync.
|
|
2396
2504
|
localMtime: localLstat!.mtime,
|
|
2397
2505
|
localSize: isLocalSymlink ? 0 : localLstat!.size,
|
|
2506
|
+
localSnapshot: plannedLocalSnapshot,
|
|
2398
2507
|
});
|
|
2399
2508
|
continue;
|
|
2400
2509
|
}
|
|
@@ -2454,6 +2563,7 @@ function computePullPlan(
|
|
|
2454
2563
|
localHash,
|
|
2455
2564
|
localMtime: localLstat!.mtime,
|
|
2456
2565
|
localSize: localLstat!.size,
|
|
2566
|
+
localSnapshot: plannedLocalSnapshot,
|
|
2457
2567
|
});
|
|
2458
2568
|
continue;
|
|
2459
2569
|
}
|
|
@@ -2483,7 +2593,12 @@ function computePullPlan(
|
|
|
2483
2593
|
if (journalEntry?.removedReason === "local-delete") {
|
|
2484
2594
|
continue;
|
|
2485
2595
|
}
|
|
2486
|
-
items.push({
|
|
2596
|
+
items.push({
|
|
2597
|
+
action: "tombstone-delete",
|
|
2598
|
+
remoteFile,
|
|
2599
|
+
localPath,
|
|
2600
|
+
localSnapshot: plannedLocalSnapshot,
|
|
2601
|
+
});
|
|
2487
2602
|
continue;
|
|
2488
2603
|
}
|
|
2489
2604
|
|
|
@@ -2496,15 +2611,20 @@ function computePullPlan(
|
|
|
2496
2611
|
!localExists &&
|
|
2497
2612
|
journalEntry !== undefined &&
|
|
2498
2613
|
!isTombstone(journalEntry) &&
|
|
2499
|
-
|
|
2500
|
-
journalEntry.remoteEtag !== undefined &&
|
|
2614
|
+
hasCurrentLocalDeleteIntent(journalEntry) &&
|
|
2501
2615
|
normalizeEtag(remoteFile.etag) === journalEntry.remoteEtag
|
|
2502
2616
|
) {
|
|
2503
2617
|
intentionalDeleteCandidates.push({ remoteFile, localPath });
|
|
2504
2618
|
continue;
|
|
2505
2619
|
}
|
|
2506
2620
|
|
|
2507
|
-
items.push({
|
|
2621
|
+
items.push({
|
|
2622
|
+
action: "download",
|
|
2623
|
+
remoteFile,
|
|
2624
|
+
localPath,
|
|
2625
|
+
isNew: !localExists,
|
|
2626
|
+
localSnapshot: plannedLocalSnapshot,
|
|
2627
|
+
});
|
|
2508
2628
|
}
|
|
2509
2629
|
|
|
2510
2630
|
// ── Intentional-delete bulk-asymmetry decision (pull-leg producer) ─────────
|
|
@@ -2526,6 +2646,7 @@ function computePullPlan(
|
|
|
2526
2646
|
remoteFile: cand.remoteFile,
|
|
2527
2647
|
localPath: cand.localPath,
|
|
2528
2648
|
isNew: true,
|
|
2649
|
+
localSnapshot: { kind: "absent" },
|
|
2529
2650
|
});
|
|
2530
2651
|
} else {
|
|
2531
2652
|
tombstoneEntry(journal, cand.remoteFile.key, "local-delete");
|
|
@@ -2585,7 +2706,7 @@ function computePullPlan(
|
|
|
2585
2706
|
// `propagateDeletes` plan in share.ts.
|
|
2586
2707
|
const remoteKeySet = new Set<string>();
|
|
2587
2708
|
for (const rf of remoteFiles) remoteKeySet.add(rf.key);
|
|
2588
|
-
const tombstones:
|
|
2709
|
+
const tombstones: PullPlan["tombstones"] = [];
|
|
2589
2710
|
for (const key of Object.keys(journal.files)) {
|
|
2590
2711
|
// Compare membership in POSIX space. A pre-5.47.2 Windows journal key can
|
|
2591
2712
|
// carry backslash separators; the remote LIST is always forward-slash, so a
|
|
@@ -2665,7 +2786,9 @@ function computePullPlan(
|
|
|
2665
2786
|
// we can't read local state, so we can't safely decide.
|
|
2666
2787
|
if (code !== "ENOENT") continue;
|
|
2667
2788
|
}
|
|
2668
|
-
|
|
2789
|
+
const localSnapshot = snapshotLocalState(localPath);
|
|
2790
|
+
if (localSnapshot === null) continue;
|
|
2791
|
+
tombstones.push({ key, localPath, localSnapshot });
|
|
2669
2792
|
}
|
|
2670
2793
|
|
|
2671
2794
|
return {
|
package/src/journal.ts
CHANGED
|
@@ -425,6 +425,7 @@ export function updateEntry(
|
|
|
425
425
|
remoteEtag?: string,
|
|
426
426
|
mtimeMs?: number,
|
|
427
427
|
createdBySub?: string,
|
|
428
|
+
kind?: "file" | "symlink",
|
|
428
429
|
): void {
|
|
429
430
|
const entry: JournalEntry = {
|
|
430
431
|
hash,
|
|
@@ -444,6 +445,9 @@ export function updateEntry(
|
|
|
444
445
|
if (createdBySub !== undefined && createdBySub !== "") {
|
|
445
446
|
entry.createdBySub = createdBySub;
|
|
446
447
|
}
|
|
448
|
+
if (kind !== undefined) {
|
|
449
|
+
entry.kind = kind;
|
|
450
|
+
}
|
|
447
451
|
journal.files[relativePath] = entry;
|
|
448
452
|
journal.lastSync = new Date().toISOString();
|
|
449
453
|
}
|
|
@@ -593,6 +597,30 @@ export function tombstoneEntry(
|
|
|
593
597
|
entry.removedReason = reason;
|
|
594
598
|
}
|
|
595
599
|
|
|
600
|
+
/**
|
|
601
|
+
* Mark a journal entry for a user-authorized local delete. Callers must
|
|
602
|
+
* capture the local hash and kind before removing the object; an absent path
|
|
603
|
+
* alone is never deletion intent.
|
|
604
|
+
*/
|
|
605
|
+
export function markLocalDeleteIntent(
|
|
606
|
+
journal: SyncJournal,
|
|
607
|
+
relativePath: string,
|
|
608
|
+
localHash: string,
|
|
609
|
+
localKind: "file" | "symlink",
|
|
610
|
+
): boolean {
|
|
611
|
+
const entry = journal.files[relativePath];
|
|
612
|
+
if (!entry?.remoteEtag || entry.hash !== localHash || entry.kind !== localKind) {
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
entry.localDeleteIntent = {
|
|
616
|
+
version: 1,
|
|
617
|
+
remoteEtag: entry.remoteEtag,
|
|
618
|
+
localHash,
|
|
619
|
+
localKind,
|
|
620
|
+
};
|
|
621
|
+
return true;
|
|
622
|
+
}
|
|
623
|
+
|
|
596
624
|
/** True if the entry is a tombstone (set by `tombstoneEntry`). */
|
|
597
625
|
export function isTombstone(entry: JournalEntry | undefined): boolean {
|
|
598
626
|
return !!entry && typeof entry.removedAt === "string";
|
package/src/personal-vault.ts
CHANGED
|
@@ -105,6 +105,21 @@ export interface PersonalVaultOptions {
|
|
|
105
105
|
*/
|
|
106
106
|
export const PERSONAL_VAULT_MANIFEST_KEY = "companies/manifest.yaml";
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Release-owned scaffold and reindex-generated wrappers must never be
|
|
110
|
+
* delete-propagated from the personal vault. They are recreated locally by a
|
|
111
|
+
* release or `hq reindex`, so an ordinary local absence is not user intent.
|
|
112
|
+
*/
|
|
113
|
+
export function isPersonalVaultDeletionExcluded(key: string): boolean {
|
|
114
|
+
const normalized = key.split("\\").join("/").replace(/^\.\//, "");
|
|
115
|
+
return (
|
|
116
|
+
normalized === "core" ||
|
|
117
|
+
normalized.startsWith("core/") ||
|
|
118
|
+
normalized === ".claude/skills" ||
|
|
119
|
+
normalized.startsWith(".claude/skills/")
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
108
123
|
export function computePersonalVaultPaths(
|
|
109
124
|
hqRoot: string,
|
|
110
125
|
opts: PersonalVaultOptions = {},
|
package/src/s3.ts
CHANGED
|
@@ -809,6 +809,7 @@ export async function downloadFile(
|
|
|
809
809
|
ctx: EntityContext,
|
|
810
810
|
key: string,
|
|
811
811
|
localPath: string,
|
|
812
|
+
options: { beforeReplace?: () => void } = {},
|
|
812
813
|
): Promise<{
|
|
813
814
|
metadata?: Record<string, string>;
|
|
814
815
|
contentHash?: string;
|
|
@@ -902,6 +903,7 @@ export async function downloadFile(
|
|
|
902
903
|
const tempPath = downloadTempPath(localPath);
|
|
903
904
|
try {
|
|
904
905
|
fs.symlinkSync(symlinkTarget, tempPath);
|
|
906
|
+
options.beforeReplace?.();
|
|
905
907
|
fs.renameSync(tempPath, localPath);
|
|
906
908
|
} catch (err) {
|
|
907
909
|
removeTempPath(tempPath);
|
|
@@ -1011,6 +1013,7 @@ export async function downloadFile(
|
|
|
1011
1013
|
}
|
|
1012
1014
|
}
|
|
1013
1015
|
|
|
1016
|
+
options.beforeReplace?.();
|
|
1014
1017
|
fs.renameSync(tempPath, localPath);
|
|
1015
1018
|
tempReady = false;
|
|
1016
1019
|
} finally {
|
package/src/types.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface JournalEntry {
|
|
|
26
26
|
size: number;
|
|
27
27
|
syncedAt: string;
|
|
28
28
|
direction: "up" | "down";
|
|
29
|
+
/** Kind of local object represented by this entry. */
|
|
30
|
+
kind?: "file" | "symlink";
|
|
29
31
|
/**
|
|
30
32
|
* Cognito `sub` of the file's author, captured from the object's
|
|
31
33
|
* `created-by-sub` S3 user-metadata at download time (zero extra network —
|
|
@@ -72,6 +74,17 @@ export interface JournalEntry {
|
|
|
72
74
|
*/
|
|
73
75
|
removedAt?: string;
|
|
74
76
|
removedReason?: "scope_shrink" | "narrow_apply" | "manual" | "local-delete";
|
|
77
|
+
/**
|
|
78
|
+
* Explicit local-delete intent. Unlike the legacy `removedReason` marker,
|
|
79
|
+
* this is bound to the exact synced remote revision and local object shape
|
|
80
|
+
* that the user approved for deletion.
|
|
81
|
+
*/
|
|
82
|
+
localDeleteIntent?: {
|
|
83
|
+
version: 1;
|
|
84
|
+
remoteEtag: string;
|
|
85
|
+
localHash: string;
|
|
86
|
+
localKind: "file" | "symlink";
|
|
87
|
+
};
|
|
75
88
|
/**
|
|
76
89
|
* Durable automatic-pull retention marker. Set when a scope shrink keeps an
|
|
77
90
|
* out-of-scope caller-authored or unknown-author entry on disk instead of
|