@indigoai-us/hq-cloud 6.15.30 → 6.15.31

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 (119) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +24 -13
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner-company.test.js +0 -71
  5. package/dist/bin/sync-runner-company.test.js.map +1 -1
  6. package/dist/bin/sync-runner-watch-loop.d.ts +0 -10
  7. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  8. package/dist/bin/sync-runner-watch-loop.js +13 -170
  9. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  10. package/dist/bin/sync-runner-watch-routes.d.ts +1 -16
  11. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  12. package/dist/bin/sync-runner-watch-routes.js +2 -40
  13. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  14. package/dist/bin/sync-runner-watch-routes.test.js +1 -53
  15. package/dist/bin/sync-runner-watch-routes.test.js.map +1 -1
  16. package/dist/bin/sync-runner.d.ts +3 -15
  17. package/dist/bin/sync-runner.d.ts.map +1 -1
  18. package/dist/bin/sync-runner.js +13 -66
  19. package/dist/bin/sync-runner.js.map +1 -1
  20. package/dist/bin/sync-runner.test.js +13 -433
  21. package/dist/bin/sync-runner.test.js.map +1 -1
  22. package/dist/cli/conflict.d.ts +2 -25
  23. package/dist/cli/conflict.d.ts.map +1 -1
  24. package/dist/cli/conflict.js +4 -29
  25. package/dist/cli/conflict.js.map +1 -1
  26. package/dist/cli/share.d.ts +4 -40
  27. package/dist/cli/share.d.ts.map +1 -1
  28. package/dist/cli/share.js +86 -478
  29. package/dist/cli/share.js.map +1 -1
  30. package/dist/cli/share.test.js +150 -845
  31. package/dist/cli/share.test.js.map +1 -1
  32. package/dist/cli/sync-scope.test.js +18 -147
  33. package/dist/cli/sync-scope.test.js.map +1 -1
  34. package/dist/cli/sync.d.ts +0 -20
  35. package/dist/cli/sync.d.ts.map +1 -1
  36. package/dist/cli/sync.js +29 -261
  37. package/dist/cli/sync.js.map +1 -1
  38. package/dist/cli/sync.test.js +90 -519
  39. package/dist/cli/sync.test.js.map +1 -1
  40. package/dist/journal.d.ts +0 -1
  41. package/dist/journal.d.ts.map +1 -1
  42. package/dist/journal.js +25 -155
  43. package/dist/journal.js.map +1 -1
  44. package/dist/journal.test.js +11 -29
  45. package/dist/journal.test.js.map +1 -1
  46. package/dist/object-io.d.ts +13 -34
  47. package/dist/object-io.d.ts.map +1 -1
  48. package/dist/object-io.js +29 -154
  49. package/dist/object-io.js.map +1 -1
  50. package/dist/outcome-telemetry.d.ts +1 -7
  51. package/dist/outcome-telemetry.d.ts.map +1 -1
  52. package/dist/outcome-telemetry.js +9 -15
  53. package/dist/outcome-telemetry.js.map +1 -1
  54. package/dist/outcome-telemetry.test.js +0 -15
  55. package/dist/outcome-telemetry.test.js.map +1 -1
  56. package/dist/outposts/client.d.ts +2 -2
  57. package/dist/outposts/client.d.ts.map +1 -1
  58. package/dist/outposts/client.js +3 -4
  59. package/dist/outposts/client.js.map +1 -1
  60. package/dist/outposts/command.d.ts.map +1 -1
  61. package/dist/outposts/command.js +6 -9
  62. package/dist/outposts/command.js.map +1 -1
  63. package/dist/outposts/command.test.js +2 -59
  64. package/dist/outposts/command.test.js.map +1 -1
  65. package/dist/outposts/remote-command.d.ts +8 -8
  66. package/dist/outposts/remote-command.d.ts.map +1 -1
  67. package/dist/outposts/remote-command.js +8 -10
  68. package/dist/outposts/remote-command.js.map +1 -1
  69. package/dist/prefix-coalesce.d.ts +0 -14
  70. package/dist/prefix-coalesce.d.ts.map +1 -1
  71. package/dist/prefix-coalesce.js +0 -29
  72. package/dist/prefix-coalesce.js.map +1 -1
  73. package/dist/prefix-coalesce.test.js +1 -22
  74. package/dist/prefix-coalesce.test.js.map +1 -1
  75. package/dist/skill-telemetry.d.ts +1 -6
  76. package/dist/skill-telemetry.d.ts.map +1 -1
  77. package/dist/skill-telemetry.js +8 -14
  78. package/dist/skill-telemetry.js.map +1 -1
  79. package/dist/skill-telemetry.test.js +0 -43
  80. package/dist/skill-telemetry.test.js.map +1 -1
  81. package/dist/sts-credential-refresh.test.js +2 -87
  82. package/dist/sts-credential-refresh.test.js.map +1 -1
  83. package/dist/sync/state-store.d.ts.map +1 -1
  84. package/dist/sync/state-store.js +1 -20
  85. package/dist/sync/state-store.js.map +1 -1
  86. package/dist/sync/state-store.test.js +0 -64
  87. package/dist/sync/state-store.test.js.map +1 -1
  88. package/dist/sync-core.d.ts.map +1 -1
  89. package/dist/sync-core.js +2 -7
  90. package/dist/sync-core.js.map +1 -1
  91. package/dist/sync-core.test.js +0 -36
  92. package/dist/sync-core.test.js.map +1 -1
  93. package/dist/telemetry.d.ts +6 -30
  94. package/dist/telemetry.d.ts.map +1 -1
  95. package/dist/telemetry.js +135 -331
  96. package/dist/telemetry.js.map +1 -1
  97. package/dist/telemetry.test.js +0 -403
  98. package/dist/telemetry.test.js.map +1 -1
  99. package/dist/types.d.ts +3 -26
  100. package/dist/types.d.ts.map +1 -1
  101. package/dist/watcher.d.ts +0 -76
  102. package/dist/watcher.d.ts.map +1 -1
  103. package/dist/watcher.js +3 -105
  104. package/dist/watcher.js.map +1 -1
  105. package/dist/watcher.test.js +1 -165
  106. package/dist/watcher.test.js.map +1 -1
  107. package/package.json +1 -1
  108. package/dist/bandwidth.d.ts +0 -128
  109. package/dist/bandwidth.d.ts.map +0 -1
  110. package/dist/bandwidth.js +0 -254
  111. package/dist/bandwidth.js.map +0 -1
  112. package/dist/bandwidth.test.d.ts +0 -2
  113. package/dist/bandwidth.test.d.ts.map +0 -1
  114. package/dist/bandwidth.test.js +0 -243
  115. package/dist/bandwidth.test.js.map +0 -1
  116. package/dist/journal-delta-writes.test.d.ts +0 -2
  117. package/dist/journal-delta-writes.test.d.ts.map +0 -1
  118. package/dist/journal-delta-writes.test.js +0 -140
  119. package/dist/journal-delta-writes.test.js.map +0 -1
package/dist/cli/sync.js CHANGED
@@ -8,7 +8,6 @@ import * as fs from "fs";
8
8
  import * as path from "path";
9
9
  import { VaultAuthError, VaultClient } from "../vault-client.js";
10
10
  import { readlinkOrNull } from "../lib/readlink-safe.js";
11
- import { describeError } from "../lib/describe-error.js";
12
11
  import { emitCloudTelemetry, } from "../telemetry-events.js";
13
12
  import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
14
13
  import { createSyncProgressRecorder } from "../sync-progress.js";
@@ -19,8 +18,7 @@ import { readJournal, writeJournal, hashFile, hashSymlinkTarget, updateEntry, re
19
18
  import { isGeneratedCoreMirrorKey, PERSONAL_VAULT_MANIFEST_KEY, } from "../personal-vault.js";
20
19
  import { isPersonalVaultExcluded } from "../personal-vault-exclusions.js";
21
20
  import { buildScopeShrinkPlan, applyScopeShrink, ScopeShrinkBlockedError, ScopeShrinkLargePruneError, } from "../scope-shrink.js";
22
- import { coalescePrefixes, intersectPrefixSets, isCoveredByAny, pathToScopePrefix, toScopePrefixEntries, } from "../prefix-coalesce.js";
23
- import { listRemoteForScope, POST_FILTER_THRESHOLD } from "../remote-pull.js";
21
+ import { coalescePrefixes, isCoveredByAny, } from "../prefix-coalesce.js";
24
22
  import { createIgnoreFilter } from "../ignore.js";
25
23
  import { hasRemoteChanged, isAccessDenied, resolveActiveCompany, resolveTransferConcurrency, readHostLoad, } from "../sync-core.js";
26
24
  import { isEphemeralPath, isForbiddenCompanyVaultKey, isMalformedVaultKey } from "./share.js";
@@ -328,19 +326,7 @@ async function buildPullContext(options) {
328
326
  // of the scope-shrink inputs (see `planScopeShrink`). Empty when the caller
329
327
  // passes nothing (legacy behavior: no exclusions).
330
328
  const currentExcludeSet = coalescePrefixes(options.excludePrefixes ?? []);
331
- // Targeted-pull narrowing (`--scope-path`): intersect the requested paths
332
- // with the durable scope so the LISTED scope can only shrink, never widen.
333
- // A path may be a file or a directory — we can't know without I/O — so each
334
- // requested path contributes both its exact and its directory spelling,
335
- // mirroring `effectivePushPrefixSet` on the push side.
336
- const requestedPullPrefixes = coalescePrefixes((options.pullPrefixes ?? []).flatMap((p) => [
337
- pathToScopePrefix(p),
338
- pathToScopePrefix(p.endsWith("/") ? p : p + "/"),
339
- ]));
340
- const listedPrefixSet = requestedPullPrefixes.length === 0
341
- ? currentPrefixSet
342
- : intersectPrefixSets(currentPrefixSet, requestedPullPrefixes);
343
- const remoteFiles = await listRemoteFilesForScope(ctx, syncMode, listedPrefixSet);
329
+ const remoteFiles = await listRemoteFiles(ctx);
344
330
  const fileTombstones = options.personalMode === true
345
331
  ? new Map()
346
332
  : await fetchCompanyTombstones(vaultConfig, ctx.uid);
@@ -360,60 +346,13 @@ async function buildPullContext(options) {
360
346
  remoteFiles,
361
347
  syncMode,
362
348
  currentPrefixSet,
363
- listedPrefixSet,
364
349
  currentExcludeSet,
365
350
  fileTombstones,
366
351
  downloadsSinceJournalCheckpoint: 0,
367
352
  };
368
353
  }
369
- /**
370
- * List the remote objects the current pull can actually materialize.
371
- *
372
- * Historically this was ALWAYS a full-bucket `listRemoteFiles(ctx)` — even for
373
- * a `shared`/`custom` membership whose effective scope is a handful of
374
- * prefixes — so every pull materialized the entire remote listing (tens of
375
- * thousands of RemoteFile objects on large vaults) only for `computePullPlan`
376
- * to discard most of it as `skip-out-of-scope`. Scoped pulls now list ONLY the
377
- * in-scope prefixes via `listRemoteForScope` (per-prefix ListObjectsV2 calls,
378
- * bounded parallel, deduped), which bounds the listing's memory to the scope's
379
- * actual footprint.
380
- *
381
- * `all`-mode (an unnarrowed full-vault pull, `listedPrefixSet` `[""]`) keeps
382
- * the single broad listing: fanning `all` out by top-level prefix would need a
383
- * delimiter LIST to discover the top-level names first and still union the
384
- * same total object count in one plan, so it bounds nothing today. (A
385
- * streaming planner is the real fix for `all` and is out of scope here.)
386
- *
387
- * An empty `listedPrefixSet` in a scoped mode means "nothing in scope" —
388
- * issue zero LIST calls. Consumers already treat an empty listing correctly:
389
- * the deletion sweep in `computePullPlan` only considers journal keys covered
390
- * by the LISTED scope, so keys absent because they were never listed can never
391
- * be mistaken for remote deletes.
392
- */
393
- async function listRemoteFilesForScope(ctx, syncMode, listedPrefixSet) {
394
- const entries = toScopePrefixEntries(listedPrefixSet);
395
- if (entries.some((e) => e.prefix === ""))
396
- return listRemoteFiles(ctx);
397
- if (entries.length === 0) {
398
- // `all` mode always carries `[""]`, so an empty set only occurs for a
399
- // scoped mode (empty grants, or a targeted intersection that excluded
400
- // everything). Defensive `all` branch: never silently narrow a full pull.
401
- return syncMode === "all" ? listRemoteFiles(ctx) : [];
402
- }
403
- return listRemoteForScope({
404
- ctx,
405
- scope: {
406
- companyUid: ctx.uid,
407
- syncMode,
408
- prefixSet: listedPrefixSet,
409
- strategy: listedPrefixSet.length > POST_FILTER_THRESHOLD
410
- ? "broad-postfilter"
411
- : "vend-fanout",
412
- },
413
- });
414
- }
415
354
  function planPull(run) {
416
- return computePullPlan(run.remoteFiles, run.journal, run.companyRoot, run.shouldSync, run.options.personalMode === true, run.options.teamSyncedSlugs ?? null, run.listedPrefixSet, run.fileTombstones, run.currentExcludeSet, run.options.onHeartbeat);
355
+ return computePullPlan(run.remoteFiles, run.journal, run.companyRoot, run.shouldSync, run.options.personalMode === true, run.options.teamSyncedSlugs ?? null, run.currentPrefixSet, run.fileTombstones, run.currentExcludeSet, run.options.onHeartbeat);
417
356
  }
418
357
  function emitPullPlan(emit, plan) {
419
358
  emit({
@@ -687,13 +626,12 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
687
626
  // the `.conflict-` mirror loop AND the journal false-stamp (remote etag over
688
627
  // divergent local content) that the keep path produced for these files.
689
628
  if (isCloudAuthoritative(vaultKeyForLocalPath(run.hqRoot, localPath))) {
690
- const currentSnapshot = snapshotLocalState(localPath);
691
629
  downloadItems.push({
692
630
  action: "download",
693
631
  remoteFile,
694
632
  localPath,
695
633
  isNew: false,
696
- localSnapshot: currentSnapshot ?? item.localSnapshot,
634
+ localSnapshot: item.localSnapshot,
697
635
  });
698
636
  run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
699
637
  return null;
@@ -716,24 +654,9 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
716
654
  }
717
655
  let remoteFetched = false;
718
656
  let converged = false;
719
- let executionLocal = null;
720
657
  try {
721
658
  const downloaded = await downloadFile(run.ctx, remoteFile.key, conflictAbs);
722
659
  remoteFetched = true;
723
- executionLocal = readCurrentLocalFileState(localPath);
724
- if (executionLocal === null) {
725
- // The path changed kind, disappeared, or kept moving while the remote
726
- // probe was in flight. Leave both sides untouched and let the next
727
- // per-file pass classify that new state; a stale conflict is not useful.
728
- try {
729
- fs.rmSync(conflictAbs, { force: true });
730
- }
731
- catch {
732
- /* best-effort cleanup */
733
- }
734
- counters.filesSkipped++;
735
- return null;
736
- }
737
660
  if (fs.lstatSync(conflictAbs).isSymbolicLink()) {
738
661
  const target = readlinkOrNull(conflictAbs);
739
662
  if (target === null) {
@@ -747,12 +670,11 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
747
670
  return null;
748
671
  }
749
672
  else {
750
- converged = hashSymlinkTarget(target) === executionLocal.hash;
673
+ converged = hashSymlinkTarget(target) === item.localHash;
751
674
  }
752
675
  }
753
676
  else {
754
- converged =
755
- (downloaded.contentHash ?? hashFile(conflictAbs)) === executionLocal.hash;
677
+ converged = (downloaded.contentHash ?? hashFile(conflictAbs)) === item.localHash;
756
678
  }
757
679
  }
758
680
  catch (probeErr) {
@@ -780,10 +702,6 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
780
702
  });
781
703
  }
782
704
  if (converged) {
783
- if (executionLocal === null) {
784
- counters.filesSkipped++;
785
- return null;
786
- }
787
705
  if (remoteFetched) {
788
706
  try {
789
707
  fs.rmSync(conflictAbs, { force: true });
@@ -792,11 +710,10 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
792
710
  /* best-effort cleanup; a stray identical mirror is harmless */
793
711
  }
794
712
  }
795
- updateEntry(run.journal, remoteFile.key, executionLocal.hash, executionLocal.size, "down", localPath, {
713
+ updateEntry(run.journal, remoteFile.key, item.localHash, item.localSize, "down", localPath, {
796
714
  remoteEtag: remoteFile.etag,
797
- mtimeMs: executionLocal.mtimeMs,
798
- ctimeMs: executionLocal.ctimeMs,
799
- kind: executionLocal.kind,
715
+ mtimeMs: item.localMtime.getTime(),
716
+ kind: item.localSnapshot.kind === "symlink" ? "symlink" : "file",
800
717
  });
801
718
  run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
802
719
  counters.filesSkipped++;
@@ -804,20 +721,11 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
804
721
  }
805
722
  counters.conflicts++;
806
723
  counters.conflictPaths.push(remoteFile.key);
807
- // A successful probe always decides from the local bytes captured after the
808
- // GET. Only a failed probe falls back to the preview snapshot so the existing
809
- // error-path behavior remains conservative.
810
- const localHash = executionLocal?.hash ?? item.localHash;
811
- const localSize = executionLocal?.size ?? item.localSize;
812
- const localMtime = executionLocal?.mtime ?? item.localMtime;
813
- const localSnapshot = executionLocal?.snapshot ?? item.localSnapshot;
814
- const localKind = executionLocal?.kind ??
815
- (item.localSnapshot.kind === "symlink" ? "symlink" : "file");
816
724
  const resolution = await resolveConflict({
817
725
  path: remoteFile.key,
818
- localHash,
726
+ localHash: item.localHash,
819
727
  remoteModified: remoteFile.lastModified,
820
- localModified: localMtime,
728
+ localModified: item.localMtime,
821
729
  direction: "pull",
822
730
  }, run.options.onConflict);
823
731
  run.emit({
@@ -826,9 +734,7 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
826
734
  direction: "pull",
827
735
  resolution,
828
736
  });
829
- if (resolution !== "abort" &&
830
- resolution !== "overwrite" &&
831
- resolution !== "keep") {
737
+ if (resolution !== "abort" && resolution !== "overwrite") {
832
738
  if (remoteFetched) {
833
739
  try {
834
740
  appendConflictEntry(run.hqRoot, {
@@ -838,12 +744,8 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
838
744
  detectedAt,
839
745
  side: "pull",
840
746
  machineId,
841
- localHash,
747
+ localHash: item.localHash,
842
748
  remoteHash: remoteFile.etag ? normalizeEtag(remoteFile.etag) : "",
843
- // The holding resolutions leave local alone and this sidecar keeps
844
- // the cloud copy. `keep` is indexed only after its local rename
845
- // succeeds below, so this row can never describe the wrong side.
846
- preserved: "remote",
847
749
  });
848
750
  }
849
751
  catch (mirrorErr) {
@@ -855,7 +757,7 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
855
757
  }
856
758
  }
857
759
  }
858
- else if (resolution !== "keep" && remoteFetched) {
760
+ else if (remoteFetched) {
859
761
  try {
860
762
  fs.rmSync(conflictAbs, { force: true });
861
763
  }
@@ -883,103 +785,22 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
883
785
  changedPaths: [...counters.changedPaths, ...scopeRun.changedPaths],
884
786
  };
885
787
  }
886
- // Hold the local body where it is, recording the remote etag so the conflict
887
- // cannot re-fire (#137) and arming the journal-honesty flag because the held
888
- // copy never matched that etag. The flag stops the currency-gated delete
889
- // planner propagating a delete whose currency would falsely match on HEAD.
890
- // Any genuine future download clears it by replacing the entry.
891
- const holdLocal = () => {
788
+ if (resolution === "keep" || resolution === "skip") {
892
789
  counters.filesSkipped++;
893
- updateEntry(run.journal, remoteFile.key, localHash, localSize, "down", localPath, {
790
+ updateEntry(run.journal, remoteFile.key, item.localHash, item.localSize, "down", localPath, {
894
791
  remoteEtag: remoteFile.etag,
895
- mtimeMs: localMtime.getTime(),
896
- kind: localKind,
897
- });
898
- const heldEntry = getEntry(run.journal, remoteFile.key);
899
- if (heldEntry)
900
- heldEntry.localDiverges = true;
901
- return null;
902
- };
903
- // `publish-local` is a PUSH-leg strategy: it means "send my body up". On the
904
- // pull leg there is nothing to publish, so it holds the local body and arms
905
- // the honesty flag, which is what the push leg then promotes under an
906
- // If-Match fence. Falling through to the download branch instead would
907
- // overwrite the very local copy the operator elected, which is the data loss
908
- // this strategy exists to prevent.
909
- //
910
- // `skip` is the interactive "leave this one, I will deal with it later". A
911
- // resolution that says leave it alone may not rename the operator's file, so
912
- // it holds too — it is deliberately NOT bundled with `keep` any more.
913
- if (resolution === "skip" || resolution === "publish-local") {
914
- return holdLocal();
915
- }
916
- // `keep` ADOPTS the cloud body as the working copy and preserves the local
917
- // one beside it under the `.conflict-` name.
918
- //
919
- // It used to mean the opposite — hold local, stamp the remote etag, arm
920
- // `localDiverges` — and that combination was terminal. The push leg refuses
921
- // to send a body the journal calls divergent; the pull leg sees a remote
922
- // whose etag already matches and leaves it alone; and every later `keep`
923
- // re-armed the flag. The file converged in neither direction, and because the
924
- // desktop app hardcodes `--on-conflict keep` that was the default outcome for
925
- // every real conflict. Adopting the remote clears the divergence by
926
- // construction: the entry then describes the body that is actually on disk.
927
- // The local bytes are not destroyed, only renamed.
928
- if (resolution === "keep") {
929
- if (!remoteFetched) {
930
- // The probe never produced a body (its failure is already emitted above).
931
- // Hold rather than move aside a file we have nothing to replace it with.
932
- return holdLocal();
933
- }
934
- try {
935
- // The sidecar currently holds the fetched probe copy of the remote.
936
- // Remove it explicitly before renaming: POSIX rename replaces a file at
937
- // the destination, but Windows rename rejects an occupied destination.
938
- // The download queued below re-fetches it. One extra GET buys the entire
939
- // tested download path —
940
- // symlink materialization, path codec, mtime and etag journalling —
941
- // instead of a hand-rolled three-way swap with its own crash window.
942
- fs.rmSync(conflictAbs, { force: true });
943
- fs.renameSync(localPath, conflictAbs);
944
- }
945
- catch (preserveErr) {
946
- run.emit({
947
- type: "error",
948
- path: remoteFile.key,
949
- message: `conflict keep could not preserve the local copy: ${preserveErr instanceof Error ? preserveErr.message : String(preserveErr)}`,
950
- });
951
- return holdLocal();
952
- }
953
- try {
954
- appendConflictEntry(run.hqRoot, {
955
- id: buildConflictId(originalRelative, detectedAt),
956
- originalPath: originalRelative,
957
- conflictPath: conflictRelative,
958
- detectedAt,
959
- side: "pull",
960
- machineId,
961
- localHash,
962
- remoteHash: remoteFile.etag ? normalizeEtag(remoteFile.etag) : "",
963
- preserved: "local",
964
- });
965
- }
966
- catch (mirrorErr) {
967
- run.emit({
968
- type: "error",
969
- path: remoteFile.key,
970
- message: `conflict mirror index write failed: ${mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)}`,
971
- });
972
- }
973
- downloadItems.push({
974
- action: "download",
975
- remoteFile,
976
- localPath,
977
- isNew: false,
978
- // The local body was just moved aside, so the pre-replace guard must
979
- // expect absence. Passing the original snapshot would make that guard
980
- // abort the very download it exists to protect.
981
- localSnapshot: { kind: "absent" },
792
+ mtimeMs: item.localMtime.getTime(),
793
+ kind: item.localSnapshot.kind === "symlink" ? "symlink" : "file",
982
794
  });
795
+ // Journal-honesty: we recorded the remote etag so this conflict can't
796
+ // re-fire (#137), but the KEPT local copy diverges from that remote — it
797
+ // never matched it. Flag the entry so the currency-gated delete planner
798
+ // refuses to propagate a delete for it (its currency would falsely match on
799
+ // HEAD, and the delete would destroy the divergent remote version). Any
800
+ // genuine future download clears the flag by replacing the entry.
801
+ const keptEntry = getEntry(run.journal, remoteFile.key);
802
+ if (keptEntry)
803
+ keptEntry.localDiverges = true;
983
804
  return null;
984
805
  }
985
806
  downloadItems.push({
@@ -987,7 +808,7 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
987
808
  remoteFile,
988
809
  localPath,
989
810
  isNew: false,
990
- localSnapshot,
811
+ localSnapshot: item.localSnapshot,
991
812
  });
992
813
  return null;
993
814
  }
@@ -1070,12 +891,6 @@ async function downloadOne(run, downloadItem, counters) {
1070
891
  updateEntry(run.journal, remoteFile.key, hash, size, "down", localPath, {
1071
892
  remoteEtag: remoteFile.etag,
1072
893
  mtimeMs: localLstat.mtimeMs,
1073
- // Stamped from the lstat already in hand so the next push can skip this
1074
- // file on stat alone. The two conflict-resolution `updateEntry` calls
1075
- // above deliberately do NOT stamp ctime: neither has a post-write stat
1076
- // available, and a `keep`/`publish-local` entry is marked
1077
- // `localDiverges` immediately after, which the fast path refuses anyway.
1078
- ctimeMs: localLstat.ctimeMs,
1079
894
  ...(createdBySub !== undefined ? { createdBySub } : {}),
1080
895
  kind: isLocalSymlink ? "symlink" : "file",
1081
896
  });
@@ -1137,7 +952,7 @@ async function downloadOne(run, downloadItem, counters) {
1137
952
  run.emit({
1138
953
  type: "error",
1139
954
  path: remoteFile.key,
1140
- message: describeError(err),
955
+ message: err instanceof Error ? err.message : String(err),
1141
956
  });
1142
957
  }
1143
958
  return;
@@ -1370,12 +1185,6 @@ function finalizePullRun(run, plan, scopeRun, counters) {
1370
1185
  * -files loop this event exists to end. `SyncResult.filesOutOfScope` keeps its
1371
1186
  * original both-causes meaning; only this advisory is narrowed.
1372
1187
  */
1373
- // NOTE: since scoped pulls list only their in-scope prefixes (see
1374
- // `listRemoteFilesForScope`), out-of-scope remote keys usually never enter the
1375
- // plan and this event fires only for keys a broad-postfilter listing (or a
1376
- // legacy caller passing a full listing) still surfaced. The withheld-count
1377
- // telemetry traded away here is the cost of not materializing the full remote
1378
- // listing on every scoped pull.
1379
1188
  function emitScopeMaterializationGap(run, plan) {
1380
1189
  if (run.syncMode === "all")
1381
1190
  return;
@@ -1505,36 +1314,6 @@ function matchesLocalSnapshot(localPath, expected) {
1505
1314
  return ((actual.kind !== "file" && actual.kind !== "symlink") ||
1506
1315
  actual.hash === expected.hash);
1507
1316
  }
1508
- /**
1509
- * Read the local side at the execution seam, not from the whole-run preview.
1510
- * A second hash check closes the read-to-stat window: when an active writer
1511
- * moves the file while we inspect it, the caller defers this path instead of
1512
- * manufacturing a decision from a mixed snapshot.
1513
- */
1514
- function readCurrentLocalFileState(localPath) {
1515
- const snapshot = snapshotLocalState(localPath);
1516
- if (snapshot === null || (snapshot.kind !== "file" && snapshot.kind !== "symlink")) {
1517
- return null;
1518
- }
1519
- let lstat;
1520
- try {
1521
- lstat = fs.lstatSync(localPath);
1522
- }
1523
- catch {
1524
- return null;
1525
- }
1526
- if (!matchesLocalSnapshot(localPath, snapshot))
1527
- return null;
1528
- return {
1529
- hash: snapshot.hash,
1530
- size: snapshot.kind === "symlink" ? 0 : lstat.size,
1531
- mtime: lstat.mtime,
1532
- mtimeMs: lstat.mtimeMs,
1533
- ctimeMs: lstat.ctimeMs,
1534
- kind: snapshot.kind,
1535
- snapshot,
1536
- };
1537
- }
1538
1317
  function hasCurrentLocalDeleteIntent(entry) {
1539
1318
  const intent = entry?.localDeleteIntent;
1540
1319
  return !!(intent &&
@@ -2354,17 +2133,6 @@ excludePrefixes = [], onHeartbeat) {
2354
2133
  const posixKey = toPosixKey(key);
2355
2134
  if (remoteKeySet.has(posixKey))
2356
2135
  continue;
2357
- // Listing-scope guard (targeted sync / scoped listing). The remote LIST
2358
- // now covers ONLY `prefixSet` (scoped modes list per-prefix; a targeted
2359
- // pull narrows further), so "absent from the LIST" is evidence of a remote
2360
- // delete ONLY for keys the LIST could have contained. A journal key
2361
- // outside the listed scope was simply never asked about — treating it as
2362
- // remote-deleted would mass-delete every out-of-scope local file on the
2363
- // first scoped pull. Out-of-scope journal entries stay the scope-shrink
2364
- // pass's responsibility (quarantine semantics), never this sweep's.
2365
- // `all` mode passes `[""]`, which covers every key — no behavior change.
2366
- if (!isCoveredByAny(posixKey, prefixSet))
2367
- continue;
2368
2136
  if (posixKey.startsWith(SKILLS_KEY_PREFIX) &&
2369
2137
  remoteCanonicalSkillKeys.has(canonicalVaultKeySpelling(posixKey)) &&
2370
2138
  // An EXACT FILE_TOMBSTONE for the journaled key is an authoritative