@indigoai-us/hq-cloud 6.15.31 → 6.15.32

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 (125) hide show
  1. package/dist/bandwidth.d.ts +128 -0
  2. package/dist/bandwidth.d.ts.map +1 -0
  3. package/dist/bandwidth.js +254 -0
  4. package/dist/bandwidth.js.map +1 -0
  5. package/dist/bandwidth.test.d.ts +2 -0
  6. package/dist/bandwidth.test.d.ts.map +1 -0
  7. package/dist/bandwidth.test.js +243 -0
  8. package/dist/bandwidth.test.js.map +1 -0
  9. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  10. package/dist/bin/sync-runner-company.js +13 -24
  11. package/dist/bin/sync-runner-company.js.map +1 -1
  12. package/dist/bin/sync-runner-company.test.js +71 -0
  13. package/dist/bin/sync-runner-company.test.js.map +1 -1
  14. package/dist/bin/sync-runner-watch-loop.d.ts +10 -0
  15. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  16. package/dist/bin/sync-runner-watch-loop.js +181 -16
  17. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  18. package/dist/bin/sync-runner-watch-routes.d.ts +16 -1
  19. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  20. package/dist/bin/sync-runner-watch-routes.js +40 -2
  21. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  22. package/dist/bin/sync-runner-watch-routes.test.js +53 -1
  23. package/dist/bin/sync-runner-watch-routes.test.js.map +1 -1
  24. package/dist/bin/sync-runner.d.ts +15 -3
  25. package/dist/bin/sync-runner.d.ts.map +1 -1
  26. package/dist/bin/sync-runner.js +66 -13
  27. package/dist/bin/sync-runner.js.map +1 -1
  28. package/dist/bin/sync-runner.test.js +433 -13
  29. package/dist/bin/sync-runner.test.js.map +1 -1
  30. package/dist/cli/conflict.d.ts +25 -2
  31. package/dist/cli/conflict.d.ts.map +1 -1
  32. package/dist/cli/conflict.js +29 -4
  33. package/dist/cli/conflict.js.map +1 -1
  34. package/dist/cli/share.d.ts +40 -4
  35. package/dist/cli/share.d.ts.map +1 -1
  36. package/dist/cli/share.js +478 -86
  37. package/dist/cli/share.js.map +1 -1
  38. package/dist/cli/share.test.js +845 -150
  39. package/dist/cli/share.test.js.map +1 -1
  40. package/dist/cli/sync-scope.test.js +147 -18
  41. package/dist/cli/sync-scope.test.js.map +1 -1
  42. package/dist/cli/sync.d.ts +20 -0
  43. package/dist/cli/sync.d.ts.map +1 -1
  44. package/dist/cli/sync.js +261 -29
  45. package/dist/cli/sync.js.map +1 -1
  46. package/dist/cli/sync.test.js +519 -90
  47. package/dist/cli/sync.test.js.map +1 -1
  48. package/dist/journal-delta-writes.test.d.ts +2 -0
  49. package/dist/journal-delta-writes.test.d.ts.map +1 -0
  50. package/dist/journal-delta-writes.test.js +140 -0
  51. package/dist/journal-delta-writes.test.js.map +1 -0
  52. package/dist/journal.d.ts +1 -0
  53. package/dist/journal.d.ts.map +1 -1
  54. package/dist/journal.js +155 -25
  55. package/dist/journal.js.map +1 -1
  56. package/dist/journal.test.js +29 -11
  57. package/dist/journal.test.js.map +1 -1
  58. package/dist/object-io.d.ts +34 -13
  59. package/dist/object-io.d.ts.map +1 -1
  60. package/dist/object-io.js +154 -29
  61. package/dist/object-io.js.map +1 -1
  62. package/dist/operation-lock.d.ts +12 -0
  63. package/dist/operation-lock.d.ts.map +1 -1
  64. package/dist/operation-lock.js +103 -21
  65. package/dist/operation-lock.js.map +1 -1
  66. package/dist/operation-lock.test.js +90 -0
  67. package/dist/operation-lock.test.js.map +1 -1
  68. package/dist/outcome-telemetry.d.ts +7 -1
  69. package/dist/outcome-telemetry.d.ts.map +1 -1
  70. package/dist/outcome-telemetry.js +15 -9
  71. package/dist/outcome-telemetry.js.map +1 -1
  72. package/dist/outcome-telemetry.test.js +15 -0
  73. package/dist/outcome-telemetry.test.js.map +1 -1
  74. package/dist/outposts/client.d.ts +2 -2
  75. package/dist/outposts/client.d.ts.map +1 -1
  76. package/dist/outposts/client.js +4 -3
  77. package/dist/outposts/client.js.map +1 -1
  78. package/dist/outposts/command.d.ts.map +1 -1
  79. package/dist/outposts/command.js +9 -6
  80. package/dist/outposts/command.js.map +1 -1
  81. package/dist/outposts/command.test.js +59 -2
  82. package/dist/outposts/command.test.js.map +1 -1
  83. package/dist/outposts/remote-command.d.ts +8 -8
  84. package/dist/outposts/remote-command.d.ts.map +1 -1
  85. package/dist/outposts/remote-command.js +10 -8
  86. package/dist/outposts/remote-command.js.map +1 -1
  87. package/dist/prefix-coalesce.d.ts +14 -0
  88. package/dist/prefix-coalesce.d.ts.map +1 -1
  89. package/dist/prefix-coalesce.js +29 -0
  90. package/dist/prefix-coalesce.js.map +1 -1
  91. package/dist/prefix-coalesce.test.js +22 -1
  92. package/dist/prefix-coalesce.test.js.map +1 -1
  93. package/dist/skill-telemetry.d.ts +6 -1
  94. package/dist/skill-telemetry.d.ts.map +1 -1
  95. package/dist/skill-telemetry.js +14 -8
  96. package/dist/skill-telemetry.js.map +1 -1
  97. package/dist/skill-telemetry.test.js +43 -0
  98. package/dist/skill-telemetry.test.js.map +1 -1
  99. package/dist/sts-credential-refresh.test.js +87 -2
  100. package/dist/sts-credential-refresh.test.js.map +1 -1
  101. package/dist/sync/state-store.d.ts.map +1 -1
  102. package/dist/sync/state-store.js +20 -1
  103. package/dist/sync/state-store.js.map +1 -1
  104. package/dist/sync/state-store.test.js +64 -0
  105. package/dist/sync/state-store.test.js.map +1 -1
  106. package/dist/sync-core.d.ts.map +1 -1
  107. package/dist/sync-core.js +7 -2
  108. package/dist/sync-core.js.map +1 -1
  109. package/dist/sync-core.test.js +36 -0
  110. package/dist/sync-core.test.js.map +1 -1
  111. package/dist/telemetry.d.ts +30 -6
  112. package/dist/telemetry.d.ts.map +1 -1
  113. package/dist/telemetry.js +331 -135
  114. package/dist/telemetry.js.map +1 -1
  115. package/dist/telemetry.test.js +403 -0
  116. package/dist/telemetry.test.js.map +1 -1
  117. package/dist/types.d.ts +26 -3
  118. package/dist/types.d.ts.map +1 -1
  119. package/dist/watcher.d.ts +76 -0
  120. package/dist/watcher.d.ts.map +1 -1
  121. package/dist/watcher.js +105 -3
  122. package/dist/watcher.js.map +1 -1
  123. package/dist/watcher.test.js +165 -1
  124. package/dist/watcher.test.js.map +1 -1
  125. package/package.json +1 -1
package/dist/cli/share.js CHANGED
@@ -76,9 +76,10 @@ async function remoteContentDiffers(ctx, relativePath, localHash, hqRoot) {
76
76
  }
77
77
  }
78
78
  /**
79
- * Local-only ephemeral artifacts: conflict-mirror files written by the pull
80
- * leg whenever a 3-way merge keeps local AND wants to preserve the remote
81
- * version for inspection. Format: `<orig>.conflict-<ISO-utc>-<machineHash>[.ext]`
79
+ * Local-only ephemeral artifacts: conflict-side files written whenever a
80
+ * 3-way merge preserves the displaced side for inspection. Depending on the
81
+ * selected strategy, that side may be local or remote. Format:
82
+ * `<orig>.conflict-<ISO-utc>-<machineHash>[.ext]`
82
83
  * (e.g. `.claude/CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md`,
83
84
  * or `.gitignore.conflict-2026-05-13T19-40-40Z-e5797a` — extensionless
84
85
  * originals produce no trailing dot, see `buildConflictPath` in
@@ -236,17 +237,109 @@ export const _testing = {
236
237
  resolveNamedPath,
237
238
  isWithinLexicalOrReal,
238
239
  defaultConsoleLogger,
240
+ statMatchesJournal,
239
241
  };
242
+ /** Snapshot of `absolutePath`, or null when it is missing or not a plain file. */
243
+ function statSnapshot(absolutePath) {
244
+ try {
245
+ const lstat = fs.lstatSync(absolutePath);
246
+ if (!lstat.isFile())
247
+ return null;
248
+ return { size: lstat.size, mtimeMs: lstat.mtimeMs, ctimeMs: lstat.ctimeMs };
249
+ }
250
+ catch {
251
+ return null;
252
+ }
253
+ }
254
+ function sameStatSnapshot(a, b) {
255
+ return (a !== null &&
256
+ b !== null &&
257
+ a.size === b.size &&
258
+ a.mtimeMs === b.mtimeMs &&
259
+ a.ctimeMs === b.ctimeMs);
260
+ }
261
+ /**
262
+ * The `ctimeMs` it is safe to stamp alongside a hash, or undefined.
263
+ *
264
+ * `hashedStat` is the stat the hash was computed against. If the file has moved
265
+ * since, the hash in hand describes bytes that are no longer on disk, and
266
+ * pairing it with the current timestamps would let every later pass skip the
267
+ * new body on stat alone. Returning undefined leaves the entry without a ctime,
268
+ * which the skip gate refuses — so the next pass hashes it.
269
+ */
270
+ function ctimeToStamp(hashedStat, lstat) {
271
+ if (!hashedStat)
272
+ return undefined;
273
+ const current = {
274
+ size: lstat.size,
275
+ mtimeMs: lstat.mtimeMs,
276
+ ctimeMs: lstat.ctimeMs,
277
+ };
278
+ return sameStatSnapshot(hashedStat, current) ? lstat.ctimeMs : undefined;
279
+ }
280
+ /**
281
+ * True when a timestamp pair is too coarse to be evidence of anything.
282
+ *
283
+ * FAT/exFAT, some FUSE mounts and cached network filesystems round timestamps
284
+ * to whole seconds (FAT's mtime granularity is two), and may synthesize ctime
285
+ * from mtime. On such a volume a same-size rewrite inside one tick leaves both
286
+ * fields equal to the journal's, and the skip gate would strand the edit
287
+ * forever. Every filesystem this gate can safely run on — APFS, ext4, NTFS —
288
+ * records sub-second precision, so a pair sitting exactly on the second is
289
+ * treated as untrustworthy and sent back to hashing.
290
+ *
291
+ * The cost of the false positive is one hash: on a real filesystem a file whose
292
+ * mtime AND ctime both land exactly on a second boundary is rare, and it is
293
+ * simply hashed as it was before the fast path existed.
294
+ */
295
+ function timestampsTooCoarseToTrust(mtimeMs, ctimeMs) {
296
+ return mtimeMs % 1000 === 0 && ctimeMs % 1000 === 0;
297
+ }
240
298
  /**
241
- * Pure Stage-1 pass for push: walk the candidate file list, hash each one,
242
- * apply the size-limit and skip-unchanged gates, and return a classified
243
- * plan plus aggregate counts. No S3 calls, no journal writes, no event
244
- * emission.
299
+ * True when `absolutePath` provably has not been written since its journal
300
+ * entry was stamped, judged from stat alone.
301
+ *
302
+ * Fails closed. Anything unusual about the entry — no stat recorded, a
303
+ * tombstone, a pending local delete, a pull-held divergence, a symlink record,
304
+ * or a local object that is no longer a plain file — returns false and sends
305
+ * the caller down the hashing path, which is the pre-fast-path behaviour.
245
306
  *
246
- * The conflict count is intentionally absent from the returned `PushPlan` —
247
- * detecting a push conflict requires a remote HEAD that we defer to Stage 2.
248
- * Consumers that want a conflict count get it from the `complete` event.
307
+ * The three fields are compared exactly, on purpose. `mtimeMs` from `lstat`
308
+ * carries sub-millisecond precision that `utimes` cannot reproduce, so a
309
+ * restored timestamp differs from the original anyway — but that is an
310
+ * accident of precision, not a guarantee, and nothing here leans on it.
311
+ * `ctimeMs` is the field doing the real work.
249
312
  */
313
+ function statMatchesJournal(absolutePath, entry) {
314
+ if (!entry)
315
+ return false;
316
+ if (!entry.hash)
317
+ return false;
318
+ if (entry.kind === "symlink")
319
+ return false;
320
+ if (entry.localDiverges)
321
+ return false;
322
+ if (entry.removedAt !== undefined)
323
+ return false;
324
+ if (entry.localDeleteIntent !== undefined)
325
+ return false;
326
+ if (entry.mtimeMs === undefined || entry.ctimeMs === undefined)
327
+ return false;
328
+ if (timestampsTooCoarseToTrust(entry.mtimeMs, entry.ctimeMs))
329
+ return false;
330
+ let lstat;
331
+ try {
332
+ lstat = fs.lstatSync(absolutePath);
333
+ }
334
+ catch {
335
+ return false;
336
+ }
337
+ if (!lstat.isFile())
338
+ return false;
339
+ return (lstat.size === entry.size &&
340
+ lstat.mtimeMs === entry.mtimeMs &&
341
+ lstat.ctimeMs === entry.ctimeMs);
342
+ }
250
343
  function computePushPlan(filesToShare, journal, skipUnchanged, onHeartbeat) {
251
344
  const items = [];
252
345
  for (const entry of filesToShare) {
@@ -264,7 +357,10 @@ function computePushPlan(filesToShare, journal, skipUnchanged, onHeartbeat) {
264
357
  const localHash = hashSymlinkTarget(entry.target);
265
358
  if (skipUnchanged) {
266
359
  const existing = journal.files[relativePath];
267
- if (existing && existing.hash === localHash) {
360
+ // `!localDiverges`: see the regular-file gate below — a divergent entry
361
+ // stores the current LOCAL hash, so hash equality here proves only that
362
+ // local has not moved since the pull-side hold, never that it matches remote.
363
+ if (existing && existing.hash === localHash && !existing.localDiverges) {
268
364
  items.push({ action: "skip-unchanged", absolutePath, relativePath });
269
365
  continue;
270
366
  }
@@ -284,28 +380,59 @@ function computePushPlan(filesToShare, journal, skipUnchanged, onHeartbeat) {
284
380
  items.push({ action: "skip-size-limit", absolutePath, relativePath });
285
381
  continue;
286
382
  }
287
- // Stat metadata is not content identity: tools can rewrite a file with
288
- // the same byte length and restore its mtime. Hash every regular file so
289
- // such an edit cannot be skipped indefinitely.
383
+ // Stat fast path. Reading and hashing every regular file every pass costs
384
+ // one full read of the vault — hundreds of thousands of files on a real
385
+ // install — to answer a question stat can usually answer for free.
386
+ //
387
+ // The gate is size + mtime + ctime, and ctime is what makes it safe. mtime
388
+ // is forgeable: `cp -p`, `touch -r` and `rsync --times` put an old mtime
389
+ // back on new bytes, so a size+mtime check would skip such an edit forever
390
+ // — the failure this code previously hashed everything to avoid. ctime is
391
+ // the inode-change stamp and userspace cannot set it; the very utimes call
392
+ // that restores mtime moves ctime to now. A file that clears all three has
393
+ // not been written since we hashed it.
394
+ //
395
+ // Deliberately NOT skipped here:
396
+ // - entries with no recorded mtime/ctime (written before stat tracking,
397
+ // or by a downloader that did not stamp) — hashed once, then stamped
398
+ // by the restamp below so the next pass is free;
399
+ // - `localDiverges` entries — see the hash gate below for why equality
400
+ // proves nothing about them.
401
+ if (skipUnchanged && statMatchesJournal(absolutePath, journal.files[relativePath])) {
402
+ items.push({ action: "skip-unchanged", absolutePath, relativePath });
403
+ continue;
404
+ }
405
+ // Snapshot stat either side of the read. If the two differ the file was
406
+ // rewritten while we hashed it, so `localHash` may describe bytes that are
407
+ // already gone — and nothing downstream may stamp a timestamp against it.
408
+ const statBeforeHash = statSnapshot(absolutePath);
290
409
  const localHash = hashFile(absolutePath);
410
+ const statAfterHash = statSnapshot(absolutePath);
411
+ const statAtHash = sameStatSnapshot(statBeforeHash, statAfterHash)
412
+ ? statAfterHash ?? undefined
413
+ : undefined;
291
414
  if (skipUnchanged) {
292
415
  const existing = journal.files[relativePath];
293
- if (existing && existing.hash === localHash) {
416
+ // A `localDiverges` entry records the hash of the KEPT LOCAL body, so
417
+ // `existing.hash === localHash` is true for every such file by
418
+ // construction. Skipping on that equality asserts "local matches remote",
419
+ // which is precisely the claim the flag exists to deny — and it would
420
+ // strand the file: the runner's push leg always passes skipUnchanged,
421
+ // so the conflict path that could publish it never runs, and the pull leg
422
+ // sees an unchanged remote and re-arms the flag. Route these to the HEAD
423
+ // + conflict path instead and let the resolution decide.
424
+ if (existing && existing.hash === localHash && !existing.localDiverges) {
294
425
  // The hash proved the bytes are unchanged — a no-op skip. Capture the
295
426
  // current (mtimeMs, size) so the journal metadata stays accurate. Only
296
427
  // attach when the stored stat actually differs (avoid a needless
297
428
  // journal write on an already-current entry). Stat failure → no
298
429
  // restamp; the skip still stands.
299
430
  let restamp;
300
- try {
301
- const lstat = fs.lstatSync(absolutePath);
302
- if (lstat.isFile() &&
303
- (existing.mtimeMs !== lstat.mtimeMs || existing.size !== lstat.size)) {
304
- restamp = { mtimeMs: lstat.mtimeMs, size: lstat.size };
305
- }
306
- }
307
- catch {
308
- /* best-effort; a stat error just forgoes the fast-path refresh */
431
+ if (statAtHash &&
432
+ (existing.mtimeMs !== statAtHash.mtimeMs ||
433
+ existing.ctimeMs !== statAtHash.ctimeMs ||
434
+ existing.size !== statAtHash.size)) {
435
+ restamp = statAtHash;
309
436
  }
310
437
  items.push({ action: "skip-unchanged", absolutePath, relativePath, restamp });
311
438
  continue;
@@ -319,6 +446,7 @@ function computePushPlan(filesToShare, journal, skipUnchanged, onHeartbeat) {
319
446
  relativePath,
320
447
  localHash,
321
448
  size,
449
+ ...(statAtHash ? { statAtHash } : {}),
322
450
  });
323
451
  }
324
452
  let filesToUpload = 0;
@@ -335,6 +463,52 @@ function computePushPlan(filesToShare, journal, skipUnchanged, onHeartbeat) {
335
463
  }
336
464
  return { items, filesToUpload, bytesToUpload, filesToSkip };
337
465
  }
466
+ /** Re-read one collected path at the execution seam. */
467
+ function recollectPushEntry(item) {
468
+ let lstat;
469
+ try {
470
+ lstat = fs.lstatSync(item.absolutePath);
471
+ }
472
+ catch {
473
+ return null;
474
+ }
475
+ if (lstat.isSymbolicLink()) {
476
+ const target = readlinkOrNull(item.absolutePath);
477
+ return target === null
478
+ ? null
479
+ : {
480
+ kind: "symlink",
481
+ absolutePath: item.absolutePath,
482
+ relativePath: item.relativePath,
483
+ target,
484
+ };
485
+ }
486
+ return lstat.isFile()
487
+ ? {
488
+ kind: "file",
489
+ absolutePath: item.absolutePath,
490
+ relativePath: item.relativePath,
491
+ }
492
+ : null;
493
+ }
494
+ /**
495
+ * Turn the whole-run push plan into a preview only. The item returned here was
496
+ * classified from the bytes and journal state that exist immediately before a
497
+ * worker starts, so callers never upload with a hash captured near pass entry.
498
+ */
499
+ function replanPushItemAtExecution(item, journal, skipUnchanged, onHeartbeat) {
500
+ const current = recollectPushEntry(item);
501
+ if (current === null)
502
+ return null;
503
+ return computePushPlan([current], journal, skipUnchanged, onHeartbeat).items[0] ?? null;
504
+ }
505
+ function uploadBodyMatchesPlan(planned, current) {
506
+ if (planned.kind !== current.kind || planned.localHash !== current.localHash) {
507
+ return false;
508
+ }
509
+ return planned.kind === "file" ||
510
+ (current.kind === "symlink" && planned.target === current.target);
511
+ }
338
512
  /**
339
513
  * Thrown by `share()` when a caller-named path cannot be pushed and
340
514
  * `unreachablePathPolicy` is `"error"` (the default). Raised while the plans
@@ -805,75 +979,115 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
805
979
  (!run.ctx.uid.startsWith("prs_") && run.vaultConfig
806
980
  ? await fetchCompanyTombstones(run.vaultConfig, run.ctx.uid)
807
981
  : new Map());
808
- const uploadItems = [];
809
- for (const item of pushPlan.items) {
982
+ const isUploadSuppressedByTombstone = (item) => {
983
+ if (fileTombstones.size === 0)
984
+ return false;
985
+ const tombstone = fileTombstones.get(toPosixKey(item.relativePath));
986
+ if (tombstone === undefined)
987
+ return false;
988
+ const entry = run.journal.files[item.relativePath];
989
+ return entry !== undefined && entry.hash === item.localHash;
990
+ };
991
+ // Prime from the preview's likely uploads for transport efficiency. The
992
+ // candidate is refreshed after the plan event and tombstone suppression is
993
+ // applied BEFORE any presigns are minted. The queue below still re-plans
994
+ // every preview item before admission, so a later edit cannot disappear from
995
+ // the pass or carry a stale hash into its journal entry.
996
+ const initiallyPlannedUploads = [];
997
+ for (const previewItem of pushPlan.items) {
998
+ if (previewItem.action !== "upload")
999
+ continue;
1000
+ const item = replanPushItemAtExecution(previewItem, run.journal, run.skipUnchanged === true, run.options.onHeartbeat);
1001
+ if (item?.action === "upload" &&
1002
+ !isUploadSuppressedByTombstone(item)) {
1003
+ initiallyPlannedUploads.push(item);
1004
+ }
1005
+ }
1006
+ await primeUploads(run.ctx, initiallyPlannedUploads.map((it) => ({
1007
+ key: it.relativePath,
1008
+ localPath: it.absolutePath,
1009
+ isSymlink: it.kind === "symlink",
1010
+ author: run.options.author,
1011
+ })));
1012
+ // Warm the GET presigns the per-item conflict HEAD (remoteMeta) reuses, so a
1013
+ // large upload set doesn't mint one presign per HEAD and burst/trip the
1014
+ // presign breaker. Mirrors the new-files + tombstone pre-primes on the pull.
1015
+ await primeObjectTransport(run.ctx, "get", initiallyPlannedUploads.map((it) => it.relativePath));
1016
+ let aborted = false;
1017
+ let abortFlightConflictPaths = [];
1018
+ let hadUploadFailure = false;
1019
+ const settleFreshSkip = (item) => {
810
1020
  if (item.action === "skip-size-limit") {
811
1021
  // A file over the size cap is a permanent, benign skip — NOT an error.
812
- // Emitting it as `type: "error"` pushed it into the runner's `errors[]`,
813
- // so every watch pass returned exit 2 and the menubar reported an
814
- // "auto-sync watcher exited unexpectedly (code=Some(2))" crash on every
815
- // tick (the HQ-SYNC-4 flood across user machines). Surface it for visibility, but
816
- // do not let it flip the pass to a non-zero exit.
817
1022
  let bytes = 0;
818
1023
  try {
819
1024
  bytes = fs.statSync(item.absolutePath).size;
820
1025
  }
821
1026
  catch {
822
- // Best-effort size for display only; a stat race must not break the sync.
1027
+ // Best-effort size for display only; a stat race must not break sync.
823
1028
  }
824
1029
  run.emit({
825
1030
  type: "skip-size-limit",
826
1031
  path: item.relativePath,
827
1032
  bytes,
828
1033
  });
829
- counters.filesSkipped++;
830
- continue;
831
1034
  }
832
- if (item.action === "upload") {
833
- if (fileTombstones.size > 0) {
834
- const ts = fileTombstones.get(toPosixKey(item.relativePath));
835
- if (ts !== undefined) {
836
- const entry = run.journal.files[item.relativePath];
837
- if (entry && entry.hash === item.localHash) {
838
- counters.filesSuppressedByTombstone++;
839
- run.emit({
840
- type: "upload-suppressed-tombstone",
841
- path: item.relativePath,
842
- deletedAt: ts.deletedAt,
843
- });
844
- continue;
845
- }
846
- }
847
- }
848
- uploadItems.push(item);
849
- continue;
850
- }
851
- if (item.restamp) {
1035
+ else if (item.restamp) {
852
1036
  const existing = run.journal.files[item.relativePath];
853
1037
  if (existing && existing.hash) {
854
1038
  existing.mtimeMs = item.restamp.mtimeMs;
1039
+ existing.ctimeMs = item.restamp.ctimeMs;
855
1040
  existing.size = item.restamp.size;
856
1041
  }
857
1042
  }
858
1043
  counters.filesSkipped++;
859
- }
860
- await primeUploads(run.ctx, uploadItems.map((it) => ({
861
- key: it.relativePath,
862
- localPath: it.absolutePath,
863
- isSymlink: it.kind === "symlink",
864
- author: run.options.author,
865
- })));
866
- // Warm the GET presigns the per-item conflict HEAD (remoteMeta) reuses, so a
867
- // large upload set doesn't mint one presign per HEAD and burst/trip the
868
- // presign breaker. Mirrors the new-files + tombstone pre-primes on the pull.
869
- await primeObjectTransport(run.ctx, "get", uploadItems.map((it) => it.relativePath));
870
- let aborted = false;
871
- let abortFlightConflictPaths = [];
872
- let hadUploadFailure = false;
873
- const processUploadItem = async (item) => {
1044
+ };
1045
+ const suppressFreshTombstone = (item) => {
1046
+ if (!isUploadSuppressedByTombstone(item))
1047
+ return false;
1048
+ const tombstone = fileTombstones.get(toPosixKey(item.relativePath));
1049
+ if (tombstone === undefined)
1050
+ return false;
1051
+ counters.filesSuppressedByTombstone++;
1052
+ run.emit({
1053
+ type: "upload-suppressed-tombstone",
1054
+ path: item.relativePath,
1055
+ deletedAt: tombstone.deletedAt,
1056
+ });
1057
+ return true;
1058
+ };
1059
+ const processUploadItem = async (item, replanAttempts = 0) => {
874
1060
  if (aborted)
875
1061
  return;
876
1062
  const { absolutePath, relativePath, localHash } = item;
1063
+ const refreshUploadItem = () => {
1064
+ const refreshed = replanPushItemAtExecution(item, run.journal, run.skipUnchanged === true, run.options.onHeartbeat);
1065
+ if (refreshed === null) {
1066
+ counters.filesSkipped++;
1067
+ return null;
1068
+ }
1069
+ if (refreshed.action !== "upload") {
1070
+ settleFreshSkip(refreshed);
1071
+ return null;
1072
+ }
1073
+ if (suppressFreshTombstone(refreshed))
1074
+ return null;
1075
+ return refreshed;
1076
+ };
1077
+ const restartForChangedBody = async (refreshed) => {
1078
+ // An actively-written file must never become a synthetic conflict or a
1079
+ // journal row for bytes we did not upload. Bound retries so a hot log
1080
+ // cannot monopolise a worker; the next sync pass will try it again.
1081
+ if (replanAttempts >= 3) {
1082
+ counters.filesSkipped++;
1083
+ return;
1084
+ }
1085
+ await processUploadItem(refreshed, replanAttempts + 1);
1086
+ };
1087
+ // Set when `publish-local` was chosen for a FRESH two-sided conflict rather
1088
+ // than for the `localDiverges` recovery. The two cases need different write
1089
+ // fences — see the precondition block below.
1090
+ let fenceOnObservedRemote = false;
877
1091
  // Cloud-authoritative paths (server-regenerated: company-brief.md, board.json,
878
1092
  // ontology/ signals/ sources/) are PULL-WINS and server-owned. The push leg must
879
1093
  // NEVER upload the local copy over them — not just on a two-sided conflict, but on
@@ -904,12 +1118,54 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
904
1118
  }
905
1119
  throw headErr;
906
1120
  }
1121
+ // HEAD is an await boundary. Re-read the local object afterwards and
1122
+ // restart the whole per-file decision if it moved while the remote check
1123
+ // was in flight. The old HEAD result must not classify the new body.
1124
+ const afterHead = refreshUploadItem();
1125
+ if (afterHead === null)
1126
+ return;
1127
+ if (!uploadBodyMatchesPlan(item, afterHead)) {
1128
+ await restartForChangedBody(afterHead);
1129
+ return;
1130
+ }
907
1131
  // Journal entry for this key (if any). Used both for 3-way conflict
908
1132
  // classification and for the write fence below: Prefer last-synced
909
1133
  // `remoteEtag` over live HEAD so a peer-advanced object cannot be
910
1134
  // silently LWW-overwritten by a journaled stale local body (Ace-shaped
911
1135
  // vault regression 2026-08-03).
912
1136
  const journalEntry = run.journal.files[relativePath];
1137
+ // A `localDiverges` entry whose remote object is GONE (HEAD null — peer
1138
+ // delete, or an operator purge with no tombstone) must not fall through to
1139
+ // the ordinary recreation path below. That path fences with
1140
+ // `If-None-Match: *`, which succeeds precisely because the object is absent,
1141
+ // so the divergent local body would resurrect a key the peer deliberately
1142
+ // removed — the exact opposite of this strategy's promise that local is
1143
+ // promoted ONLY while the remote still sits at the recorded etag. A missing
1144
+ // object is the strongest possible statement that it does not.
1145
+ //
1146
+ // Scoped to divergent entries: `keep`'s recreate-once behaviour for an
1147
+ // ordinary journaled key whose remote was deleted is unchanged.
1148
+ if (!remoteMeta && journalEntry?.localDiverges) {
1149
+ conflictPaths.push(relativePath);
1150
+ const resolution = await resolveConflictSerialized({
1151
+ path: relativePath,
1152
+ localHash,
1153
+ direction: "push",
1154
+ });
1155
+ run.emit({ type: "conflict", path: relativePath, direction: "push", resolution });
1156
+ if (resolution === "abort") {
1157
+ aborted = true;
1158
+ abortFlightConflictPaths = [...conflictPaths];
1159
+ return;
1160
+ }
1161
+ if (resolution !== "overwrite") {
1162
+ // `publish-local`, `keep` and `skip` all decline to resurrect. Only an
1163
+ // explicit `overwrite` — consent given with the deletion in view — falls
1164
+ // through to the create below.
1165
+ counters.filesSkipped++;
1166
+ return;
1167
+ }
1168
+ }
913
1169
  if (remoteMeta) {
914
1170
  const localChanged = !!journalEntry && journalEntry.hash !== localHash;
915
1171
  const remoteChanged = !!journalEntry && hasRemoteChanged(remoteMeta, journalEntry);
@@ -932,6 +1188,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
932
1188
  updateEntry(run.journal, relativePath, localHash, lstat.size, "up", absolutePath, {
933
1189
  remoteEtag: remoteMeta.etag,
934
1190
  mtimeMs: lstat.mtimeMs,
1191
+ ctimeMs: ctimeToStamp(item.kind === "file" ? item.statAtHash : undefined, lstat),
935
1192
  kind: item.kind,
936
1193
  });
937
1194
  // Same checkpoint contract as an actual upload. After repeated
@@ -945,8 +1202,8 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
945
1202
  counters.filesSkipped++;
946
1203
  return;
947
1204
  }
948
- // `localDiverges` means a prior pull-keep stamped the remote etag for
949
- // re-fire silence (#137) while local never matched that remote. The
1205
+ // `localDiverges` means a prior pull-side hold stamped the remote etag
1206
+ // for re-fire silence (#137) while local never matched that remote. The
950
1207
  // journal-baseline If-Match fence alone would SUCCEED when remote is
951
1208
  // still at that etag, silently promoting the divergent local to write
952
1209
  // authority. Route through conflict (keep/abort/overwrite) instead.
@@ -973,18 +1230,52 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
973
1230
  abortFlightConflictPaths = [...conflictPaths];
974
1231
  return;
975
1232
  }
976
- if (resolution === "keep" || resolution === "skip") {
1233
+ if (resolution === "keep") {
1234
+ await writePushConflictMirror(run, item, normalizeEtag(remoteMeta.etag), "local");
1235
+ counters.filesSkipped++;
1236
+ return;
1237
+ }
1238
+ if (resolution === "skip") {
977
1239
  if (isFreshCollision) {
978
- await writePushConflictMirror(run, item, normalizeEtag(remoteMeta.etag));
1240
+ await writePushConflictMirror(run, item, normalizeEtag(remoteMeta.etag), "remote");
979
1241
  }
980
1242
  counters.filesSkipped++;
981
1243
  return;
982
1244
  }
983
- // overwrite: fall through to conditional PUT (or unfenced retry on 412).
1245
+ // `publish-local` on a FRESH two-sided conflict must fence on the etag
1246
+ // just observed by HEAD, not on the journal's. Here `remoteChanged` is
1247
+ // true, which means the journal etag is stale BY DEFINITION — fencing on
1248
+ // it would 412 every time and the file could never publish, leaving the
1249
+ // operator's explicit choice permanently unhonoured. Consent given at
1250
+ // this prompt is consent against the remote they were just shown.
1251
+ //
1252
+ // The `divergentLocalHonesty` recovery keeps the JOURNAL etag: there the
1253
+ // recorded etag is exactly the version the operator saw when they chose
1254
+ // to keep, and a mismatch is the signal to fail closed.
1255
+ fenceOnObservedRemote =
1256
+ resolution === "publish-local" && !divergentLocalHonesty;
1257
+ // `overwrite` falls through to the conditional PUT and, on 412, retries
1258
+ // with no precondition — unconditional by design.
1259
+ //
1260
+ // `publish-local` falls through to the SAME conditional PUT but stops
1261
+ // there: see the 412 handler below, which fails it closed instead of
1262
+ // retrying unfenced. That fence is the whole point of the strategy — it
1263
+ // promotes the divergent local body only while the remote is still the
1264
+ // version the operator saw when they chose to keep it.
984
1265
  }
985
1266
  }
986
1267
  if (aborted)
987
1268
  return;
1269
+ // Conflict probes and prompts above are also await boundaries. Make the
1270
+ // PUT inseparable from a final fresh local classification: if the body
1271
+ // changed, discard this decision and begin again with a new HEAD.
1272
+ const beforePut = refreshUploadItem();
1273
+ if (beforePut === null)
1274
+ return;
1275
+ if (!uploadBodyMatchesPlan(item, beforePut)) {
1276
+ await restartForChangedBody(beforePut);
1277
+ return;
1278
+ }
988
1279
  // Write fence: when remote exists, prefer last-synced journal remoteEtag
989
1280
  // over live HEAD so a peer-advanced object fails closed (412 → keep/
990
1281
  // abort/overwrite). Live-HEAD If-Match alone only covers HEAD→PUT TOCTOU.
@@ -994,19 +1285,22 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
994
1285
  // If-Match against a missing object cannot succeed and false-conflicts.
995
1286
  // Legacy entries without remoteEtag fall back to live HEAD (prior behavior).
996
1287
  const precondition = remoteMeta
997
- ? journalEntry?.remoteEtag
998
- ? { ifMatch: journalEntry.remoteEtag }
999
- : { ifMatch: remoteMeta.etag }
1288
+ ? fenceOnObservedRemote
1289
+ ? { ifMatch: normalizeEtag(remoteMeta.etag) }
1290
+ : journalEntry?.remoteEtag
1291
+ ? { ifMatch: journalEntry.remoteEtag }
1292
+ : { ifMatch: remoteMeta.etag }
1000
1293
  : { ifNoneMatch: "*" };
1001
1294
  const performUpload = async (pc) => {
1002
1295
  const isSymlinkUpload = item.kind === "symlink";
1003
1296
  const lstat = fs.lstatSync(absolutePath);
1004
1297
  const size = isSymlinkUpload ? 0 : lstat.size;
1005
1298
  const mtimeMs = lstat.mtimeMs;
1299
+ const ctimeMs = ctimeToStamp(item.kind === "file" ? item.statAtHash : undefined, lstat);
1006
1300
  const { etag } = isSymlinkUpload
1007
1301
  ? await uploadSymlink(run.ctx, item.target, relativePath, run.options.author, pc)
1008
1302
  : await uploadFile(run.ctx, absolutePath, relativePath, run.options.author, pc);
1009
- updateEntry(run.journal, relativePath, localHash, size, "up", absolutePath, { remoteEtag: etag, mtimeMs, kind: item.kind });
1303
+ updateEntry(run.journal, relativePath, localHash, size, "up", absolutePath, { remoteEtag: etag, mtimeMs, ctimeMs, kind: item.kind });
1010
1304
  if (run.message) {
1011
1305
  run.journal.files[relativePath] = {
1012
1306
  ...run.journal.files[relativePath],
@@ -1055,6 +1349,22 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
1055
1349
  abortFlightConflictPaths = [...conflictPaths];
1056
1350
  return;
1057
1351
  }
1352
+ // A 412 here means the remote advanced between the plan and the PUT.
1353
+ // `publish-local` consented to replacing ONE specific version; that
1354
+ // version is gone, so the consent no longer covers what is there now.
1355
+ // Fail closed — mirror the remote and skip — rather than inheriting
1356
+ // `overwrite`'s unfenced retry, which would clobber the peer's write.
1357
+ //
1358
+ // Keyed off the FRESH resolution above, not the earlier one: an
1359
+ // interactive operator re-prompted at this point may legitimately
1360
+ // escalate to `overwrite`, and that is new consent against the remote
1361
+ // as it stands now. Only a repeated `publish-local` — which is what the
1362
+ // non-interactive strategy returns — stays fenced.
1363
+ if (resolution === "publish-local") {
1364
+ await writePushConflictMirror(run, item, remoteMeta ? normalizeEtag(remoteMeta.etag) : "", "remote");
1365
+ counters.filesSkipped++;
1366
+ return;
1367
+ }
1058
1368
  if (resolution === "overwrite") {
1059
1369
  try {
1060
1370
  await performUpload(undefined);
@@ -1071,7 +1381,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
1071
1381
  }
1072
1382
  return;
1073
1383
  }
1074
- await writePushConflictMirror(run, item, remoteMeta ? normalizeEtag(remoteMeta.etag) : "");
1384
+ await writePushConflictMirror(run, item, remoteMeta ? normalizeEtag(remoteMeta.etag) : "", resolution === "keep" ? "local" : "remote");
1075
1385
  counters.filesSkipped++;
1076
1386
  return;
1077
1387
  }
@@ -1097,19 +1407,35 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
1097
1407
  // unless nothing is in flight, so a single file larger than the entire
1098
1408
  // budget still makes progress and the pool never deadlocks.
1099
1409
  const byteBudget = resolveUploadByteBudget(run.hostLoadAtStart.availableMemFraction);
1100
- const queue = [...uploadItems];
1410
+ const queue = [...pushPlan.items];
1101
1411
  const inFlight = new Set();
1102
1412
  let inFlightBytes = 0;
1103
1413
  while (queue.length > 0 || inFlight.size > 0) {
1104
1414
  while (!aborted && inFlight.size < TRANSFER_CONCURRENCY && queue.length > 0) {
1415
+ const previewItem = queue[0];
1416
+ const item = replanPushItemAtExecution(previewItem, run.journal, run.skipUnchanged === true, run.options.onHeartbeat);
1417
+ if (item === null) {
1418
+ queue.shift();
1419
+ counters.filesSkipped++;
1420
+ continue;
1421
+ }
1422
+ if (item.action !== "upload") {
1423
+ queue.shift();
1424
+ settleFreshSkip(item);
1425
+ continue;
1426
+ }
1427
+ if (suppressFreshTombstone(item)) {
1428
+ queue.shift();
1429
+ continue;
1430
+ }
1105
1431
  // Reserve the file's CURRENT on-disk size, not the planned size: an
1106
1432
  // active log/session file can grow between planning and upload, and
1107
1433
  // uploadFile buffers the whole current body. A stale planned size would
1108
1434
  // let several "small" files all be admitted and then blow the budget.
1109
- const nextBytes = currentUploadBytes(queue[0]);
1435
+ const nextBytes = currentUploadBytes(item);
1110
1436
  if (inFlight.size > 0 && inFlightBytes + nextBytes > byteBudget)
1111
1437
  break;
1112
- const item = queue.shift();
1438
+ queue.shift();
1113
1439
  inFlightBytes += nextBytes;
1114
1440
  const p = processUploadItem(item)
1115
1441
  .catch((err) => {
@@ -1136,13 +1462,17 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
1136
1462
  }
1137
1463
  return { aborted, abortFlightConflictPaths, workerErrors, hadUploadFailure };
1138
1464
  }
1139
- async function writePushConflictMirror(run, item, remoteHash) {
1465
+ async function writePushConflictMirror(run, item, remoteHash, preserved) {
1466
+ let localMovedAside = false;
1467
+ let remoteAdopted = false;
1468
+ let conflictAbs;
1469
+ let effectiveRemoteHash = remoteHash;
1140
1470
  try {
1141
1471
  const detectedAt = new Date().toISOString();
1142
1472
  const machineId = readShortMachineId(run.hqRoot);
1143
1473
  const originalRelative = vaultKeyForLocalPath(run.hqRoot, item.absolutePath);
1144
1474
  const conflictRelative = buildConflictPath(originalRelative, detectedAt, machineId);
1145
- const conflictAbs = localPathForVaultKey(run.hqRoot, conflictRelative);
1475
+ conflictAbs = localPathForVaultKey(run.hqRoot, conflictRelative);
1146
1476
  if (!isMaterializationPathStillContained(run.syncRoot, conflictAbs)) {
1147
1477
  run.emit({
1148
1478
  type: "error",
@@ -1151,7 +1481,42 @@ async function writePushConflictMirror(run, item, remoteHash) {
1151
1481
  });
1152
1482
  }
1153
1483
  else {
1154
- await downloadFile(run.ctx, item.relativePath, conflictAbs);
1484
+ if (preserved === "local") {
1485
+ // A keep reached after a 412 may have been planned against no object or
1486
+ // an older one. Refresh the version token before journalling the body we
1487
+ // are about to adopt; retain the observed token if HEAD is unavailable.
1488
+ const latestRemote = await headRemoteFile(run.ctx, item.relativePath);
1489
+ if (latestRemote?.etag) {
1490
+ effectiveRemoteHash = normalizeEtag(latestRemote.etag);
1491
+ }
1492
+ // `keep` has one meaning in both directions: cloud wins the working
1493
+ // name while the displaced local body remains recoverable beside it.
1494
+ // Rename first, then reuse the ordinary tested download path for the
1495
+ // replacement instead of hand-rolling a swap around an ad-hoc GET.
1496
+ fs.renameSync(item.absolutePath, conflictAbs);
1497
+ localMovedAside = true;
1498
+ const downloaded = await downloadFile(run.ctx, item.relativePath, item.absolutePath);
1499
+ remoteAdopted = true;
1500
+ const localLstat = fs.lstatSync(item.absolutePath);
1501
+ const isLocalSymlink = localLstat.isSymbolicLink();
1502
+ const remoteBodyHash = isLocalSymlink
1503
+ ? hashSymlinkTarget(fs.readlinkSync(item.absolutePath))
1504
+ : (downloaded?.contentHash ?? hashFile(item.absolutePath));
1505
+ const remoteBodySize = isLocalSymlink
1506
+ ? 0
1507
+ : (downloaded?.contentSize ?? localLstat.size);
1508
+ const createdBySub = downloaded?.metadata?.["created-by-sub"];
1509
+ updateEntry(run.journal, item.relativePath, remoteBodyHash, remoteBodySize, "down", item.absolutePath, {
1510
+ remoteEtag: effectiveRemoteHash,
1511
+ mtimeMs: localLstat.mtimeMs,
1512
+ ctimeMs: localLstat.ctimeMs,
1513
+ ...(createdBySub !== undefined ? { createdBySub } : {}),
1514
+ kind: isLocalSymlink ? "symlink" : "file",
1515
+ });
1516
+ }
1517
+ else {
1518
+ await downloadFile(run.ctx, item.relativePath, conflictAbs);
1519
+ }
1155
1520
  appendConflictEntry(run.hqRoot, {
1156
1521
  id: buildConflictId(originalRelative, detectedAt),
1157
1522
  originalPath: originalRelative,
@@ -1160,13 +1525,40 @@ async function writePushConflictMirror(run, item, remoteHash) {
1160
1525
  side: "push",
1161
1526
  machineId,
1162
1527
  localHash: item.localHash,
1163
- remoteHash,
1528
+ remoteHash: effectiveRemoteHash,
1529
+ preserved,
1164
1530
  });
1531
+ if (preserved === "local") {
1532
+ // Persist the converged remote-backed entry immediately. A conflict
1533
+ // resolution is a completed transfer even though the push counter is
1534
+ // recorded as skipped, and an interrupted pass must not revive it.
1535
+ checkpointShareJournal(run);
1536
+ }
1165
1537
  }
1166
1538
  }
1167
1539
  catch (mirrorErr) {
1540
+ // If adopting the remote failed before it became the working copy, put the
1541
+ // local body back. The remote remains authoritative in cloud, so replacing
1542
+ // any partial local materialization is safe and leaves this pass no worse
1543
+ // than the pre-conflict state.
1544
+ if (localMovedAside && !remoteAdopted && conflictAbs) {
1545
+ try {
1546
+ fs.rmSync(item.absolutePath, { force: true });
1547
+ fs.renameSync(conflictAbs, item.absolutePath);
1548
+ localMovedAside = false;
1549
+ }
1550
+ catch (rollbackErr) {
1551
+ run.emit({
1552
+ type: "error",
1553
+ path: item.relativePath,
1554
+ message: "conflict keep rollback failed: " + describeError(rollbackErr),
1555
+ });
1556
+ }
1557
+ }
1168
1558
  if (mirrorErr instanceof VaultAuthError)
1169
1559
  throw mirrorErr;
1560
+ if (mirrorErr instanceof JournalCheckpointError)
1561
+ throw mirrorErr;
1170
1562
  if (mirrorErr instanceof WindowsSymlinkPrivilegeError) {
1171
1563
  // The remote symlink record cannot be materialized to a conflict mirror
1172
1564
  // on this unprivileged Windows host. Emit the deliberate skip (never