@indigoai-us/hq-cloud 6.10.1 → 6.11.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.
@@ -3839,6 +3839,71 @@ describe("currency-gated: journal version 2 fixtures", () => {
3839
3839
  expect(litter).toEqual([]);
3840
3840
  });
3841
3841
 
3842
+ // Efficiency follow-up to the directive lock above: a touched-but-identical
3843
+ // file currently re-hashes on EVERY sync because the no-op skip never
3844
+ // refreshes the journal's (mtimeMs, size). Re-stamp them on the skip so the
3845
+ // NEXT sync short-circuits on lstat alone. Pure performance — still no
3846
+ // upload, no conflict, no mirror.
3847
+ it("re-stamps journal mtimeMs after a no-op skip so the next sync hits the fast-path", async () => {
3848
+ const companyRoot = path.join(tmpDir, "companies", "acme");
3849
+ fs.mkdirSync(companyRoot, { recursive: true });
3850
+ const testFile = path.join(companyRoot, "touched-then-fast.md");
3851
+ fs.writeFileSync(testFile, "bytes that never change");
3852
+ const originalLstat = fs.lstatSync(testFile);
3853
+
3854
+ const { hashFile } = await import("../journal.js");
3855
+ const realHash = hashFile(testFile);
3856
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
3857
+ fs.writeFileSync(
3858
+ journalPath,
3859
+ JSON.stringify({
3860
+ version: "2",
3861
+ lastSync: new Date().toISOString(),
3862
+ files: {
3863
+ "touched-then-fast.md": {
3864
+ hash: realHash,
3865
+ size: originalLstat.size,
3866
+ mtimeMs: originalLstat.mtimeMs,
3867
+ syncedAt: new Date().toISOString(),
3868
+ direction: "up",
3869
+ },
3870
+ },
3871
+ pulls: [],
3872
+ }),
3873
+ );
3874
+
3875
+ // Bump mtime — fast-path misses, hash runs, hash matches → no-op skip.
3876
+ const futureTime = new Date(Date.now() + 120_000);
3877
+ fs.utimesSync(testFile, futureTime, futureTime);
3878
+ const bumpedLstat = fs.lstatSync(testFile);
3879
+ expect(bumpedLstat.mtimeMs).not.toBe(originalLstat.mtimeMs);
3880
+
3881
+ const result = await share({
3882
+ paths: [testFile],
3883
+ company: "acme",
3884
+ vaultConfig: mockConfig,
3885
+ hqRoot: tmpDir,
3886
+ skipUnchanged: true,
3887
+ });
3888
+
3889
+ // Still a pure skip — never an upload or conflict.
3890
+ expect(result.filesUploaded).toBe(0);
3891
+ expect(result.filesSkipped).toBe(1);
3892
+ expect(uploadFile).not.toHaveBeenCalled();
3893
+
3894
+ // Journal mtimeMs is now the BUMPED value (not the stale original), so the
3895
+ // next sync's lstat fast-path matches and skips without re-hashing.
3896
+ const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
3897
+ expect(journalAfter.files["touched-then-fast.md"].mtimeMs).toBe(
3898
+ bumpedLstat.mtimeMs,
3899
+ );
3900
+ expect(journalAfter.files["touched-then-fast.md"].size).toBe(
3901
+ bumpedLstat.size,
3902
+ );
3903
+ // Content hash untouched — the file genuinely didn't change.
3904
+ expect(journalAfter.files["touched-then-fast.md"].hash).toBe(realHash);
3905
+ });
3906
+
3842
3907
  it("stamps mtimeMs on the journal entry after a successful upload", async () => {
3843
3908
  const companyRoot = path.join(tmpDir, "companies", "acme");
3844
3909
  fs.mkdirSync(companyRoot, { recursive: true });
package/src/cli/share.ts CHANGED
@@ -288,6 +288,13 @@ type PushPlanItem =
288
288
  action: "skip-unchanged";
289
289
  absolutePath: string;
290
290
  relativePath: string;
291
+ // Present only when the lstat fast-path MISSED but the re-hash confirmed
292
+ // the bytes are unchanged (a touched-but-identical file, or a pre-5.36
293
+ // entry with no mtimeMs). Carries the current (mtimeMs, size) so the
294
+ // apply loop can refresh the journal entry — otherwise the fast-path
295
+ // keeps missing and re-hashes the file on every future sync. No content
296
+ // change: this never uploads or conflicts.
297
+ restamp?: { mtimeMs: number; size: number };
291
298
  };
292
299
 
293
300
  interface PushPlan {
@@ -394,7 +401,27 @@ function computePushPlan(
394
401
  if (skipUnchanged) {
395
402
  const existing = journal.files[relativePath];
396
403
  if (existing && existing.hash === localHash) {
397
- items.push({ action: "skip-unchanged", absolutePath, relativePath });
404
+ // Reaching here means the fast-path did NOT short-circuit (mtime or
405
+ // size moved, or the entry predates mtimeMs) yet the re-hash proved the
406
+ // bytes are unchanged — a no-op skip. Capture the current (mtimeMs,
407
+ // size) so the apply loop refreshes the journal; without it the
408
+ // fast-path keeps missing and re-hashes this file on every sync. Only
409
+ // attach when the stored stat actually differs (avoid a needless
410
+ // journal write on an already-current entry). Stat failure → no
411
+ // restamp; the skip still stands.
412
+ let restamp: { mtimeMs: number; size: number } | undefined;
413
+ try {
414
+ const lstat = fs.lstatSync(absolutePath);
415
+ if (
416
+ lstat.isFile() &&
417
+ (existing.mtimeMs !== lstat.mtimeMs || existing.size !== lstat.size)
418
+ ) {
419
+ restamp = { mtimeMs: lstat.mtimeMs, size: lstat.size };
420
+ }
421
+ } catch {
422
+ /* best-effort; a stat error just forgoes the fast-path refresh */
423
+ }
424
+ items.push({ action: "skip-unchanged", absolutePath, relativePath, restamp });
398
425
  continue;
399
426
  }
400
427
  }
@@ -1045,6 +1072,20 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
1045
1072
  continue;
1046
1073
  }
1047
1074
  if (item.action === "skip-unchanged") {
1075
+ // Refresh the journal's (mtimeMs, size) for a touched-but-identical file
1076
+ // so the next sync's lstat fast-path matches and skips without re-hashing.
1077
+ // Guarded on item.restamp (only set when the fast-path missed AND the
1078
+ // stored stat actually differs) and on the entry still carrying a hash —
1079
+ // never alters the content hash, so this stays a pure no-op skip. The
1080
+ // journal is persisted unconditionally by writeJournal at the end of the
1081
+ // run, so this survives even when nothing uploads.
1082
+ if (item.restamp) {
1083
+ const existing = journal.files[item.relativePath];
1084
+ if (existing && existing.hash) {
1085
+ existing.mtimeMs = item.restamp.mtimeMs;
1086
+ existing.size = item.restamp.size;
1087
+ }
1088
+ }
1048
1089
  filesSkipped++;
1049
1090
  continue;
1050
1091
  }
@@ -69,9 +69,25 @@ const mockVendResponse = {
69
69
  expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
70
70
  };
71
71
 
72
- function setupFetchMock() {
72
+ function setupFetchMock(
73
+ opts: { tombstones?: Array<{ key: string; deletedAt: string }> } = {},
74
+ ) {
73
75
  const fetchMock = vi.fn().mockImplementation(async (url: string) => {
74
76
  const urlStr = String(url);
77
+ // FILE_TOMBSTONE read surface (delete-resync). Defaults to an empty list so
78
+ // every legacy test exercises the no-suppression path; tests that need
79
+ // suppression pass `{ tombstones }`.
80
+ if (urlStr.includes("/v1/files/tombstones")) {
81
+ return {
82
+ ok: true,
83
+ status: 200,
84
+ json: async () => ({
85
+ companyUid: mockEntity.uid,
86
+ tombstones: opts.tombstones ?? [],
87
+ }),
88
+ text: async () => "",
89
+ };
90
+ }
75
91
  // New per-user-namespace slug resolver (hq-pro PR 67). Returns the
76
92
  // mockEntity's uid as the in-namespace match; the caller then
77
93
  // re-fetches it via `/entity/{uid}`, which is matched by the
@@ -1049,6 +1065,230 @@ describe("sync", () => {
1049
1065
  expect(journal.files["docs/really-deleted.md"]).toBeUndefined();
1050
1066
  });
1051
1067
 
1068
+ // ── delete-resync: FILE_TOMBSTONE suppression (issue [6]) ──────────────────
1069
+ // These four cover the brief's regression contract: a scoped-delete tombstone
1070
+ // makes a delete STICK across a pull even when the object is still in the
1071
+ // remote LIST (a peer re-pushed it / the delete-marker hasn't propagated),
1072
+ // while a genuine re-create still syncs and a post-tombstone local edit is
1073
+ // never silently destroyed.
1074
+
1075
+ it("delete-resync: a tombstoned key present in the remote LIST is removed locally and NOT re-downloaded", async () => {
1076
+ // The core stays-deleted assertion. A peer ran `hq files delete docs/` (the
1077
+ // scoped-delete path), which wrote a FILE_TOMBSTONE. This machine still
1078
+ // holds the file and the object is still visible in the remote LIST (a peer
1079
+ // re-push, or an un-propagated delete-marker). Pre-fix the planner saw
1080
+ // "remote present, local present, unchanged" and KEPT it; worse, on a fresh
1081
+ // machine "remote present, local absent" re-DOWNLOADED it — the resurrection
1082
+ // loop. Now the FILE_TOMBSTONE (newer than the remote object) is
1083
+ // authoritative: delete locally, drop the journal entry, never download.
1084
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1085
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
1086
+ const sharedPath = path.join(companyRoot, "docs", "shared.md");
1087
+ fs.writeFileSync(sharedPath, "shared content");
1088
+ const crypto = await import("node:crypto");
1089
+ const baseline = crypto
1090
+ .createHash("sha256")
1091
+ .update("shared content")
1092
+ .digest("hex");
1093
+ const now = Date.now();
1094
+ fs.writeFileSync(
1095
+ journalPath,
1096
+ JSON.stringify({
1097
+ version: "1",
1098
+ lastSync: new Date(now - 120_000).toISOString(),
1099
+ files: {
1100
+ "docs/shared.md": {
1101
+ hash: baseline,
1102
+ size: 14,
1103
+ syncedAt: new Date(now - 120_000).toISOString(),
1104
+ direction: "down",
1105
+ remoteEtag: "remote-old",
1106
+ },
1107
+ },
1108
+ }),
1109
+ );
1110
+ // Remote object is the STALE deleted version: lastModified BEFORE the
1111
+ // tombstone, still present in the LIST.
1112
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1113
+ {
1114
+ key: "docs/shared.md",
1115
+ size: 14,
1116
+ lastModified: new Date(now - 60_000),
1117
+ etag: '"remote-old"',
1118
+ },
1119
+ ]);
1120
+ // FILE_TOMBSTONE deleted AFTER the remote object's lastModified.
1121
+ setupFetchMock({
1122
+ tombstones: [
1123
+ { key: "docs/shared.md", deletedAt: new Date(now).toISOString() },
1124
+ ],
1125
+ });
1126
+
1127
+ const result = await sync({
1128
+ company: "acme",
1129
+ vaultConfig: mockConfig,
1130
+ hqRoot: tmpDir,
1131
+ });
1132
+
1133
+ // Stays deleted locally …
1134
+ expect(fs.existsSync(sharedPath)).toBe(false);
1135
+ // … and was NEVER re-downloaded (no download, no conflict-probe download).
1136
+ expect(s3Module.downloadFile).not.toHaveBeenCalled();
1137
+ expect(result.filesDownloaded).toBe(0);
1138
+ expect(result.filesTombstoned).toBeGreaterThanOrEqual(1);
1139
+ // Journal entry dropped so it converges (no re-tombstone next run).
1140
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1141
+ expect(journal.files["docs/shared.md"]).toBeUndefined();
1142
+ });
1143
+
1144
+ it("delete-resync: a re-created object NEWER than the tombstone DOES sync (tombstone is not permanent)", async () => {
1145
+ // The tombstone must not permanently suppress a key. After a delete, a user
1146
+ // writes a brand-new object at the same path; its lastModified post-dates
1147
+ // the tombstone, so it is a genuine re-create and must download normally.
1148
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1149
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
1150
+ const recreatedPath = path.join(companyRoot, "docs", "recreated.md");
1151
+ const now = Date.now();
1152
+ // Empty journal + absent local: the file was deleted earlier; this is a
1153
+ // fresh re-create arriving from the vault.
1154
+ fs.writeFileSync(
1155
+ journalPath,
1156
+ JSON.stringify({ version: "1", lastSync: new Date(now).toISOString(), files: {} }),
1157
+ );
1158
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1159
+ {
1160
+ key: "docs/recreated.md",
1161
+ size: 17,
1162
+ lastModified: new Date(now), // newer than the tombstone below
1163
+ etag: '"remote-new"',
1164
+ },
1165
+ ]);
1166
+ setupFetchMock({
1167
+ tombstones: [
1168
+ {
1169
+ key: "docs/recreated.md",
1170
+ deletedAt: new Date(now - 3_600_000).toISOString(), // an hour ago
1171
+ },
1172
+ ],
1173
+ });
1174
+
1175
+ const result = await sync({
1176
+ company: "acme",
1177
+ vaultConfig: mockConfig,
1178
+ hqRoot: tmpDir,
1179
+ });
1180
+
1181
+ // Re-create wins: downloaded, present locally, not tombstoned.
1182
+ expect(s3Module.downloadFile).toHaveBeenCalled();
1183
+ expect(fs.existsSync(recreatedPath)).toBe(true);
1184
+ expect(result.filesDownloaded).toBeGreaterThanOrEqual(1);
1185
+ expect(result.filesTombstoned).toBe(0);
1186
+ });
1187
+
1188
+ it("delete-resync: a local edit made after the tombstone surfaces a CONFLICT, not a silent delete", async () => {
1189
+ // Safety guard: the authoritative delete must never destroy unsynced local
1190
+ // work. This machine edited the file locally (diverged from the synced
1191
+ // baseline) and only then learned of the tombstone — surface a conflict and
1192
+ // PRESERVE local, rather than unlinking it.
1193
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1194
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
1195
+ const editedPath = path.join(companyRoot, "docs", "edited.md");
1196
+ fs.writeFileSync(editedPath, "my local edits");
1197
+ const crypto = await import("node:crypto");
1198
+ const baseline = crypto
1199
+ .createHash("sha256")
1200
+ .update("original synced content")
1201
+ .digest("hex");
1202
+ const now = Date.now();
1203
+ fs.writeFileSync(
1204
+ journalPath,
1205
+ JSON.stringify({
1206
+ version: "1",
1207
+ lastSync: new Date(now - 120_000).toISOString(),
1208
+ files: {
1209
+ "docs/edited.md": {
1210
+ hash: baseline, // local has since diverged → localChanged
1211
+ size: 23,
1212
+ syncedAt: new Date(now - 120_000).toISOString(),
1213
+ direction: "down",
1214
+ remoteEtag: "remote-old",
1215
+ },
1216
+ },
1217
+ }),
1218
+ );
1219
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1220
+ {
1221
+ key: "docs/edited.md",
1222
+ size: 23,
1223
+ lastModified: new Date(now - 60_000),
1224
+ etag: '"remote-old"',
1225
+ },
1226
+ ]);
1227
+ setupFetchMock({
1228
+ tombstones: [
1229
+ { key: "docs/edited.md", deletedAt: new Date(now).toISOString() },
1230
+ ],
1231
+ });
1232
+
1233
+ const result = await sync({
1234
+ company: "acme",
1235
+ onConflict: "keep",
1236
+ vaultConfig: mockConfig,
1237
+ hqRoot: tmpDir,
1238
+ });
1239
+
1240
+ // Conflict surfaced; local edits preserved; nothing tombstoned away.
1241
+ expect(result.conflicts).toBeGreaterThanOrEqual(1);
1242
+ expect(result.conflictPaths).toContain("docs/edited.md");
1243
+ expect(fs.existsSync(editedPath)).toBe(true);
1244
+ expect(fs.readFileSync(editedPath, "utf-8")).toBe("my local edits");
1245
+ expect(result.filesTombstoned).toBe(0);
1246
+ });
1247
+
1248
+ it("delete-resync: suppression is per-key and the fetch is company-scoped (no cross-tenant bleed)", async () => {
1249
+ // Two guards in one: (a) a tombstone for `docs/a.md` must NOT suppress a
1250
+ // different key `docs/b.md` — suppression is keyed exactly; (b) the
1251
+ // tombstone fetch is scoped to the ACTIVE company's uid, so a sync for one
1252
+ // company never consults another company's tombstones (the client half of
1253
+ // the cross-tenant isolation the server enforces via the
1254
+ // FILE_TOMBSTONE#<companyUid># begins_with query).
1255
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1256
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
1257
+ const now = Date.now();
1258
+ fs.writeFileSync(
1259
+ journalPath,
1260
+ JSON.stringify({ version: "1", lastSync: new Date(now).toISOString(), files: {} }),
1261
+ );
1262
+ // a.md = stale tombstoned version (suppressed); b.md = a normal new file.
1263
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1264
+ { key: "docs/a.md", size: 10, lastModified: new Date(now - 60_000), etag: '"a-old"' },
1265
+ { key: "docs/b.md", size: 10, lastModified: new Date(now), etag: '"b-new"' },
1266
+ ]);
1267
+ const fetchMock = setupFetchMock({
1268
+ tombstones: [
1269
+ { key: "docs/a.md", deletedAt: new Date(now).toISOString() },
1270
+ ],
1271
+ });
1272
+
1273
+ const result = await sync({
1274
+ company: "acme",
1275
+ vaultConfig: mockConfig,
1276
+ hqRoot: tmpDir,
1277
+ });
1278
+
1279
+ // a.md suppressed (never downloaded); b.md downloaded normally.
1280
+ expect(fs.existsSync(path.join(companyRoot, "docs", "a.md"))).toBe(false);
1281
+ expect(fs.existsSync(path.join(companyRoot, "docs", "b.md"))).toBe(true);
1282
+ expect(result.filesDownloaded).toBe(1);
1283
+
1284
+ // The tombstone read was scoped to THIS company's uid.
1285
+ const tombstoneCall = fetchMock.mock.calls.find((c) =>
1286
+ String(c[0]).includes("/v1/files/tombstones"),
1287
+ );
1288
+ expect(tombstoneCall).toBeDefined();
1289
+ expect(String(tombstoneCall![0])).toContain(`company=${mockEntity.uid}`);
1290
+ });
1291
+
1052
1292
  it("does NOT tombstone keys when HEAD returns AccessDenied (Codex P1 — defensive STS skip)", async () => {
1053
1293
  // Same Codex P1, AccessDenied branch: a guest STS session may deny
1054
1294
  // both LIST and HEAD on out-of-scope keys. The tombstone planner