@indigoai-us/hq-cloud 6.12.5 → 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/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,
@@ -213,7 +215,7 @@ export type SyncProgressEvent =
213
215
  path: string;
214
216
  journalEtag: string;
215
217
  remoteEtag: string;
216
- reason: "stale-etag" | "legacy-no-etag" | "bulk-asymmetry";
218
+ reason: "stale-etag" | "legacy-no-etag" | "bulk-asymmetry" | "divergent-local";
217
219
  }
218
220
  | {
219
221
  /**
@@ -1209,6 +1211,14 @@ async function executeConflictItem(
1209
1211
  remoteFile.etag,
1210
1212
  item.localMtime.getTime(),
1211
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;
1212
1222
  return null;
1213
1223
  }
1214
1224
 
@@ -1738,6 +1748,15 @@ interface PullPlan {
1738
1748
  * caller (`sync()`) is responsible for emitting the resulting plan event
1739
1749
  * before iterating `items`.
1740
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
+
1741
1760
  function computePullPlan(
1742
1761
  remoteFiles: RemoteFile[],
1743
1762
  journal: SyncJournal,
@@ -1758,6 +1777,12 @@ function computePullPlan(
1758
1777
  fileTombstones: ReadonlyMap<string, CompanyTombstone> = new Map(),
1759
1778
  ): PullPlan {
1760
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
+ }> = [];
1761
1786
 
1762
1787
  for (const remoteFile of remoteFiles) {
1763
1788
  const localPath = resolveContainedVaultPath(companyRoot, remoteFile.key);
@@ -1851,6 +1876,23 @@ function computePullPlan(
1851
1876
  const tombstoneSuppresses =
1852
1877
  tombstone !== undefined &&
1853
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
+ });
1854
1896
 
1855
1897
  // lstat (not existsSync/statSync) handles three cases the legacy
1856
1898
  // checks got wrong for symlinks:
@@ -2074,15 +2116,67 @@ function computePullPlan(
2074
2116
  // re-create": the classic resurrection ("remote present → I'm behind →
2075
2117
  // download") the planner must NOT do. Route to `tombstone-delete` so the
2076
2118
  // executor drops any stale journal entry and the key stays gone, instead of
2077
- // pulling the deleted object back in.
2078
- if (tombstoneSuppresses) {
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
+ }
2079
2131
  items.push({ action: "tombstone-delete", remoteFile, localPath });
2080
2132
  continue;
2081
2133
  }
2082
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
+
2083
2152
  items.push({ action: "download", remoteFile, localPath, isNew: !localExists });
2084
2153
  }
2085
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
+
2086
2180
  let filesToDownload = 0;
2087
2181
  let bytesToDownload = 0;
2088
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];
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
  /**