@indigoai-us/hq-cloud 6.14.8 → 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.
Files changed (67) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +8 -0
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner-planning.d.ts +1 -1
  5. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  6. package/dist/bin/sync-runner-planning.js +15 -1
  7. package/dist/bin/sync-runner-planning.js.map +1 -1
  8. package/dist/bin/sync-runner.d.ts +12 -0
  9. package/dist/bin/sync-runner.d.ts.map +1 -1
  10. package/dist/bin/sync-runner.js +26 -0
  11. package/dist/bin/sync-runner.js.map +1 -1
  12. package/dist/bin/sync-runner.test.js +68 -1
  13. package/dist/bin/sync-runner.test.js.map +1 -1
  14. package/dist/cli/share.d.ts.map +1 -1
  15. package/dist/cli/share.js +89 -14
  16. package/dist/cli/share.js.map +1 -1
  17. package/dist/cli/share.test.js +105 -3
  18. package/dist/cli/share.test.js.map +1 -1
  19. package/dist/cli/sync-scope.test.js +3 -1
  20. package/dist/cli/sync-scope.test.js.map +1 -1
  21. package/dist/cli/sync.d.ts +7 -7
  22. package/dist/cli/sync.d.ts.map +1 -1
  23. package/dist/cli/sync.js +166 -82
  24. package/dist/cli/sync.js.map +1 -1
  25. package/dist/cli/sync.test.js +292 -27
  26. package/dist/cli/sync.test.js.map +1 -1
  27. package/dist/journal.d.ts +7 -1
  28. package/dist/journal.d.ts.map +1 -1
  29. package/dist/journal.js +22 -1
  30. package/dist/journal.js.map +1 -1
  31. package/dist/manifest-reconcile.d.ts +27 -0
  32. package/dist/manifest-reconcile.d.ts.map +1 -0
  33. package/dist/manifest-reconcile.js +120 -0
  34. package/dist/manifest-reconcile.js.map +1 -0
  35. package/dist/manifest-reconcile.test.d.ts +2 -0
  36. package/dist/manifest-reconcile.test.d.ts.map +1 -0
  37. package/dist/manifest-reconcile.test.js +97 -0
  38. package/dist/manifest-reconcile.test.js.map +1 -0
  39. package/dist/personal-vault.d.ts +6 -0
  40. package/dist/personal-vault.d.ts.map +1 -1
  41. package/dist/personal-vault.js +12 -0
  42. package/dist/personal-vault.js.map +1 -1
  43. package/dist/s3.d.ts +15 -2
  44. package/dist/s3.d.ts.map +1 -1
  45. package/dist/s3.js +54 -19
  46. package/dist/s3.js.map +1 -1
  47. package/dist/s3.test.js +12 -1
  48. package/dist/s3.test.js.map +1 -1
  49. package/dist/types.d.ts +13 -0
  50. package/dist/types.d.ts.map +1 -1
  51. package/package.json +1 -1
  52. package/src/bin/sync-runner-company.ts +7 -0
  53. package/src/bin/sync-runner-planning.ts +16 -2
  54. package/src/bin/sync-runner.test.ts +82 -1
  55. package/src/bin/sync-runner.ts +43 -0
  56. package/src/cli/share.test.ts +129 -3
  57. package/src/cli/share.ts +114 -15
  58. package/src/cli/sync-scope.test.ts +3 -1
  59. package/src/cli/sync.test.ts +352 -26
  60. package/src/cli/sync.ts +226 -103
  61. package/src/journal.ts +28 -0
  62. package/src/manifest-reconcile.test.ts +107 -0
  63. package/src/manifest-reconcile.ts +160 -0
  64. package/src/personal-vault.ts +15 -0
  65. package/src/s3.test.ts +18 -0
  66. package/src/s3.ts +67 -30
  67. package/src/types.ts +13 -0
package/src/cli/share.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  wrapFilterWithPersonalVaultDefaults,
42
42
  type PersonalVaultExclusion,
43
43
  } from "../personal-vault-exclusions.js";
44
+ import { isPersonalVaultDeletionExcluded } from "../personal-vault.js";
44
45
  import { resolveConflict } from "./conflict.js";
45
46
  import type { ConflictStrategy } from "./conflict.js";
46
47
  import type { SyncProgressEvent } from "./sync.js";
@@ -1101,7 +1102,7 @@ async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
1101
1102
  run.journal,
1102
1103
  run.options.decommissionPrefixes ?? [],
1103
1104
  run.propagateDeletePolicy,
1104
- new Set([...deletePlan.toDelete, ...deletePlan.toTombstone]),
1105
+ new Set([...deletePlan.toDelete.map((item) => item.key), ...deletePlan.toTombstone]),
1105
1106
  )
1106
1107
  : [];
1107
1108
 
@@ -1240,7 +1241,11 @@ async function executeUploads(
1240
1241
  // second HQ root that shares this machine's sync journal) would otherwise read as
1241
1242
  // `localChanged && !remoteChanged`, slip past the conflict-scoped guard below, and
1242
1243
  // rewind the server's value — the `.last-run` watermark clobber. Skip before HEAD.
1243
- if (isCloudAuthoritative(relativePath)) {
1244
+ // board.json is the one push-side exception: local board appends are
1245
+ // legitimate, but must flow through the conditional PUT fence below. It
1246
+ // remains cloud-authoritative on pull so server automation still wins a
1247
+ // pull conflict.
1248
+ if (relativePath !== "board.json" && isCloudAuthoritative(relativePath)) {
1244
1249
  run.emit({ type: "reconciled", path: relativePath, direction: "push" });
1245
1250
  counters.filesSkipped++;
1246
1251
  return;
@@ -1293,6 +1298,8 @@ async function executeUploads(
1293
1298
  "up",
1294
1299
  remoteMeta.etag,
1295
1300
  lstat.mtimeMs,
1301
+ undefined,
1302
+ item.kind,
1296
1303
  );
1297
1304
  run.emit({ type: "reconciled", path: relativePath, direction: "push" });
1298
1305
  counters.filesSkipped++;
@@ -1350,7 +1357,17 @@ async function executeUploads(
1350
1357
  ? await uploadSymlink(run.ctx, item.target, relativePath, run.options.author, pc)
1351
1358
  : await uploadFile(run.ctx, absolutePath, relativePath, run.options.author, pc);
1352
1359
 
1353
- updateEntry(run.journal, relativePath, localHash, size, "up", etag, mtimeMs);
1360
+ updateEntry(
1361
+ run.journal,
1362
+ relativePath,
1363
+ localHash,
1364
+ size,
1365
+ "up",
1366
+ etag,
1367
+ mtimeMs,
1368
+ undefined,
1369
+ item.kind,
1370
+ );
1354
1371
  if (run.message) {
1355
1372
  run.journal.files[relativePath] = {
1356
1373
  ...run.journal.files[relativePath],
@@ -1501,14 +1518,39 @@ async function executeDeletes(
1501
1518
  counters: ShareCounters,
1502
1519
  filesRefusedStalePaths: string[],
1503
1520
  ): Promise<void> {
1504
- const deleteKeys = [...deletePlan.toDelete, ...decommissionPlan];
1521
+ const deleteItems = [
1522
+ ...deletePlan.toDelete.map((item) => ({ ...item, decommission: false })),
1523
+ ...decommissionPlan.map((key) => ({ key, intentVersion: null, decommission: true })),
1524
+ ];
1525
+ const deleteKeys = deleteItems.map((item) => item.key);
1505
1526
  await primeObjectTransport(run.ctx, "delete", deleteKeys);
1506
- for (const relativePath of deleteKeys) {
1527
+ for (const item of deleteItems) {
1528
+ const { key: relativePath } = item;
1507
1529
  if (run.vaultConfig && isExpiringSoon(run.ctx.expiresAt)) {
1508
1530
  run.ctx = await refreshEntityContext(run.companyRef, run.vaultConfig);
1509
1531
  }
1510
1532
  try {
1511
1533
  const entry = run.journal.files[relativePath];
1534
+ if (
1535
+ !item.decommission &&
1536
+ item.intentVersion !== null &&
1537
+ (!hasCurrentLocalDeleteIntent(entry) ||
1538
+ entry!.localDeleteIntent!.version !== item.intentVersion ||
1539
+ !isLocallyAbsent(path.join(run.syncRoot, relativePath)))
1540
+ ) {
1541
+ counters.filesRefusedStale++;
1542
+ if (filesRefusedStalePaths.length < REFUSED_STALE_PATH_CAP) {
1543
+ filesRefusedStalePaths.push(relativePath);
1544
+ }
1545
+ run.emit({
1546
+ type: "delete-refused-stale-etag",
1547
+ path: relativePath,
1548
+ journalEtag: entry?.remoteEtag ?? "<missing-delete-intent>",
1549
+ remoteEtag: "<not-checked>",
1550
+ reason: "intent-changed",
1551
+ });
1552
+ continue;
1553
+ }
1512
1554
  const size = entry?.size ?? 0;
1513
1555
  await deleteRemoteFile(run.ctx, relativePath);
1514
1556
  removeEntry(run.journal, relativePath);
@@ -2027,7 +2069,9 @@ type RefusedStaleReason =
2027
2069
  | "stale-etag"
2028
2070
  | "legacy-no-etag"
2029
2071
  | "bulk-asymmetry"
2030
- | "divergent-local";
2072
+ | "divergent-local"
2073
+ | "missing-delete-intent"
2074
+ | "intent-changed";
2031
2075
 
2032
2076
  /**
2033
2077
  * Bulk-asymmetry circuit-breaker — refuses to convert a suspiciously-large
@@ -2091,7 +2135,7 @@ function isBulkAsymmetryOverride(): boolean {
2091
2135
  * uses. Emitted as `delete-refused-stale-etag` events.
2092
2136
  */
2093
2137
  interface DeletePlan {
2094
- toDelete: string[];
2138
+ toDelete: Array<{ key: string; intentVersion: 1 | null }>;
2095
2139
  toTombstone: string[];
2096
2140
  refusedStale: Array<{
2097
2141
  key: string;
@@ -2113,6 +2157,34 @@ interface DeletePlan {
2113
2157
  };
2114
2158
  }
2115
2159
 
2160
+ function hasCurrentLocalDeleteIntent(
2161
+ entry: SyncJournal["files"][string] | undefined,
2162
+ ): boolean {
2163
+ const intent = entry?.localDeleteIntent;
2164
+ return !!(
2165
+ intent &&
2166
+ intent.version === 1 &&
2167
+ entry.remoteEtag &&
2168
+ entry.kind &&
2169
+ intent.remoteEtag === entry.remoteEtag &&
2170
+ intent.localHash === entry.hash &&
2171
+ intent.localKind === entry.kind
2172
+ );
2173
+ }
2174
+
2175
+ function isLocallyAbsent(localPath: string): boolean {
2176
+ try {
2177
+ fs.lstatSync(localPath);
2178
+ return false;
2179
+ } catch (err: unknown) {
2180
+ const code =
2181
+ err && typeof err === "object" && "code" in err
2182
+ ? (err as { code?: string }).code
2183
+ : undefined;
2184
+ return code === "ENOENT";
2185
+ }
2186
+ }
2187
+
2116
2188
  /**
2117
2189
  * Concurrency cap for the per-file HEAD-O-meter (currency-gated). Sequential
2118
2190
  * HEADs would add ~N×(50-200ms) to a sync — for the 261-mirror real-world
@@ -2216,7 +2288,7 @@ async function computeDeletePlan(
2216
2288
  // all), or queue it for HEAD (currency-gated). Keeping this synchronous
2217
2289
  // means the HEAD pass below sees a single, deduplicated candidate list
2218
2290
  // and the journal-mutation buckets are already settled before any I/O.
2219
- type HeadCandidate = { key: string; journalEtag: string };
2291
+ type HeadCandidate = { key: string; journalEtag: string; intentVersion: 1 };
2220
2292
  const headCandidates: HeadCandidate[] = [];
2221
2293
  // Litter drain bucket — kept separate from `plan.toDelete` so the
2222
2294
  // bulk-asymmetry breaker (which moves toDelete + headCandidates into
@@ -2263,6 +2335,10 @@ async function computeDeletePlan(
2263
2335
  }
2264
2336
  if (presentLocally) continue;
2265
2337
 
2338
+ if (!companyScoped && isPersonalVaultDeletionExcluded(relativeKey)) {
2339
+ continue;
2340
+ }
2341
+
2266
2342
  // Vault-litter drain (6.0.2): conflict mirrors + rescue drift markers
2267
2343
  // ALWAYS drain, bypassing `shouldSync` (which would skip them when the
2268
2344
  // parent path is in personal-vault default exclusions like
@@ -2316,10 +2392,23 @@ async function computeDeletePlan(
2316
2392
  continue;
2317
2393
  }
2318
2394
 
2395
+ if (!hasCurrentLocalDeleteIntent(entry)) {
2396
+ plan.refusedStale.push({
2397
+ key: relativeKey,
2398
+ journalEtag: entry.remoteEtag ?? "<missing-delete-intent>",
2399
+ remoteEtag: "<not-checked>",
2400
+ reason: "missing-delete-intent",
2401
+ });
2402
+ continue;
2403
+ }
2404
+
2319
2405
  if (policy === "all") {
2320
2406
  // policy:"all" is the explicit-opt-out emergency-reconcile mode; the
2321
2407
  // bulk-asymmetry guard skips this branch (caller asserted intent).
2322
- plan.toDelete.push(relativeKey);
2408
+ plan.toDelete.push({
2409
+ key: relativeKey,
2410
+ intentVersion: entry.localDeleteIntent!.version,
2411
+ });
2323
2412
  continue;
2324
2413
  }
2325
2414
  bulkCandidatePicks++;
@@ -2332,7 +2421,10 @@ async function computeDeletePlan(
2332
2421
  bulkCandidatePicks--;
2333
2422
  continue;
2334
2423
  }
2335
- plan.toDelete.push(relativeKey);
2424
+ plan.toDelete.push({
2425
+ key: relativeKey,
2426
+ intentVersion: entry.localDeleteIntent!.version,
2427
+ });
2336
2428
  continue;
2337
2429
  }
2338
2430
  // currency-gated: queue for HEAD unless the entry is legacy (no etag).
@@ -2346,7 +2438,11 @@ async function computeDeletePlan(
2346
2438
  });
2347
2439
  continue;
2348
2440
  }
2349
- headCandidates.push({ key: relativeKey, journalEtag });
2441
+ headCandidates.push({
2442
+ key: relativeKey,
2443
+ journalEtag,
2444
+ intentVersion: entry.localDeleteIntent!.version,
2445
+ });
2350
2446
  }
2351
2447
 
2352
2448
  // Bulk-asymmetry circuit-breaker. See `BULK_ASYMMETRY_*` constants for
@@ -2375,7 +2471,7 @@ async function computeDeletePlan(
2375
2471
  samplePaths.push(key);
2376
2472
  }
2377
2473
  };
2378
- for (const key of plan.toDelete) pushRefused(key);
2474
+ for (const item of plan.toDelete) pushRefused(item.key);
2379
2475
  for (const c of headCandidates) pushRefused(c.key);
2380
2476
  plan.toDelete = [];
2381
2477
  plan.bulkAsymmetry = {
@@ -2389,7 +2485,7 @@ async function computeDeletePlan(
2389
2485
  // of user content; litter cleanup is orthogonal and should never be
2390
2486
  // refused for the same reason new-litter producer-side exclusions
2391
2487
  // shouldn't block it.
2392
- plan.toDelete.push(...litterToDelete);
2488
+ plan.toDelete.push(...litterToDelete.map((key) => ({ key, intentVersion: null })));
2393
2489
  return plan;
2394
2490
  }
2395
2491
 
@@ -2436,7 +2532,10 @@ async function computeDeletePlan(
2436
2532
  }
2437
2533
  const currentEtag = normalizeEtag(remote.etag);
2438
2534
  if (currentEtag === candidate.journalEtag) {
2439
- plan.toDelete.push(candidate.key);
2535
+ plan.toDelete.push({
2536
+ key: candidate.key,
2537
+ intentVersion: candidate.intentVersion,
2538
+ });
2440
2539
  } else {
2441
2540
  plan.refusedStale.push({
2442
2541
  key: candidate.key,
@@ -2453,7 +2552,7 @@ async function computeDeletePlan(
2453
2552
  // delete loop tombstones each key identically (DeleteObject + remove from
2454
2553
  // journal). Merged at the end so the bulk-asymmetry breaker above had its
2455
2554
  // chance to NOT include litter in either the numerator or the sweep.
2456
- plan.toDelete.push(...litterToDelete);
2555
+ plan.toDelete.push(...litterToDelete.map((key) => ({ key, intentVersion: null })));
2457
2556
 
2458
2557
  return plan;
2459
2558
  }
@@ -34,11 +34,13 @@ const REMOTE: { current: Array<{ key: string; size: number; lastModified: Date;
34
34
  ],
35
35
  };
36
36
 
37
- vi.mock("../s3.js", async () => {
37
+ vi.mock("../s3.js", async (importOriginal) => {
38
+ const actual = await importOriginal<typeof import("../s3.js")>();
38
39
  const innerFs = await import("fs");
39
40
  const innerPath = await import("path");
40
41
  const { vi: innerVi } = await import("vitest");
41
42
  return {
43
+ ...actual,
42
44
  toPosixKey: (key: string) => key.split("\\").join("/"),
43
45
  uploadFile: innerVi.fn().mockResolvedValue(undefined),
44
46
  downloadFile: innerVi