@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/share.js CHANGED
@@ -76,10 +76,9 @@ async function remoteContentDiffers(ctx, relativePath, localHash, hqRoot) {
76
76
  }
77
77
  }
78
78
  /**
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]`
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]`
83
82
  * (e.g. `.claude/CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md`,
84
83
  * or `.gitignore.conflict-2026-05-13T19-40-40Z-e5797a` — extensionless
85
84
  * originals produce no trailing dot, see `buildConflictPath` in
@@ -237,109 +236,17 @@ export const _testing = {
237
236
  resolveNamedPath,
238
237
  isWithinLexicalOrReal,
239
238
  defaultConsoleLogger,
240
- statMatchesJournal,
241
239
  };
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
- }
298
240
  /**
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.
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.
306
245
  *
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.
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.
312
249
  */
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
- }
343
250
  function computePushPlan(filesToShare, journal, skipUnchanged, onHeartbeat) {
344
251
  const items = [];
345
252
  for (const entry of filesToShare) {
@@ -357,10 +264,7 @@ function computePushPlan(filesToShare, journal, skipUnchanged, onHeartbeat) {
357
264
  const localHash = hashSymlinkTarget(entry.target);
358
265
  if (skipUnchanged) {
359
266
  const existing = journal.files[relativePath];
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) {
267
+ if (existing && existing.hash === localHash) {
364
268
  items.push({ action: "skip-unchanged", absolutePath, relativePath });
365
269
  continue;
366
270
  }
@@ -380,59 +284,28 @@ function computePushPlan(filesToShare, journal, skipUnchanged, onHeartbeat) {
380
284
  items.push({ action: "skip-size-limit", absolutePath, relativePath });
381
285
  continue;
382
286
  }
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);
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.
409
290
  const localHash = hashFile(absolutePath);
410
- const statAfterHash = statSnapshot(absolutePath);
411
- const statAtHash = sameStatSnapshot(statBeforeHash, statAfterHash)
412
- ? statAfterHash ?? undefined
413
- : undefined;
414
291
  if (skipUnchanged) {
415
292
  const existing = journal.files[relativePath];
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) {
293
+ if (existing && existing.hash === localHash) {
425
294
  // The hash proved the bytes are unchanged — a no-op skip. Capture the
426
295
  // current (mtimeMs, size) so the journal metadata stays accurate. Only
427
296
  // attach when the stored stat actually differs (avoid a needless
428
297
  // journal write on an already-current entry). Stat failure → no
429
298
  // restamp; the skip still stands.
430
299
  let restamp;
431
- if (statAtHash &&
432
- (existing.mtimeMs !== statAtHash.mtimeMs ||
433
- existing.ctimeMs !== statAtHash.ctimeMs ||
434
- existing.size !== statAtHash.size)) {
435
- restamp = statAtHash;
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 */
436
309
  }
437
310
  items.push({ action: "skip-unchanged", absolutePath, relativePath, restamp });
438
311
  continue;
@@ -446,7 +319,6 @@ function computePushPlan(filesToShare, journal, skipUnchanged, onHeartbeat) {
446
319
  relativePath,
447
320
  localHash,
448
321
  size,
449
- ...(statAtHash ? { statAtHash } : {}),
450
322
  });
451
323
  }
452
324
  let filesToUpload = 0;
@@ -463,52 +335,6 @@ function computePushPlan(filesToShare, journal, skipUnchanged, onHeartbeat) {
463
335
  }
464
336
  return { items, filesToUpload, bytesToUpload, filesToSkip };
465
337
  }
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
- }
512
338
  /**
513
339
  * Thrown by `share()` when a caller-named path cannot be pushed and
514
340
  * `unreachablePathPolicy` is `"error"` (the default). Raised while the plans
@@ -979,115 +805,75 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
979
805
  (!run.ctx.uid.startsWith("prs_") && run.vaultConfig
980
806
  ? await fetchCompanyTombstones(run.vaultConfig, run.ctx.uid)
981
807
  : new Map());
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) => {
808
+ const uploadItems = [];
809
+ for (const item of pushPlan.items) {
1020
810
  if (item.action === "skip-size-limit") {
1021
811
  // 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.
1022
817
  let bytes = 0;
1023
818
  try {
1024
819
  bytes = fs.statSync(item.absolutePath).size;
1025
820
  }
1026
821
  catch {
1027
- // Best-effort size for display only; a stat race must not break sync.
822
+ // Best-effort size for display only; a stat race must not break the sync.
1028
823
  }
1029
824
  run.emit({
1030
825
  type: "skip-size-limit",
1031
826
  path: item.relativePath,
1032
827
  bytes,
1033
828
  });
829
+ counters.filesSkipped++;
830
+ continue;
1034
831
  }
1035
- else if (item.restamp) {
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) {
1036
852
  const existing = run.journal.files[item.relativePath];
1037
853
  if (existing && existing.hash) {
1038
854
  existing.mtimeMs = item.restamp.mtimeMs;
1039
- existing.ctimeMs = item.restamp.ctimeMs;
1040
855
  existing.size = item.restamp.size;
1041
856
  }
1042
857
  }
1043
858
  counters.filesSkipped++;
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) => {
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) => {
1060
874
  if (aborted)
1061
875
  return;
1062
876
  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;
1091
877
  // Cloud-authoritative paths (server-regenerated: company-brief.md, board.json,
1092
878
  // ontology/ signals/ sources/) are PULL-WINS and server-owned. The push leg must
1093
879
  // NEVER upload the local copy over them — not just on a two-sided conflict, but on
@@ -1118,54 +904,12 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
1118
904
  }
1119
905
  throw headErr;
1120
906
  }
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
- }
1131
907
  // Journal entry for this key (if any). Used both for 3-way conflict
1132
908
  // classification and for the write fence below: Prefer last-synced
1133
909
  // `remoteEtag` over live HEAD so a peer-advanced object cannot be
1134
910
  // silently LWW-overwritten by a journaled stale local body (Ace-shaped
1135
911
  // vault regression 2026-08-03).
1136
912
  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
- }
1169
913
  if (remoteMeta) {
1170
914
  const localChanged = !!journalEntry && journalEntry.hash !== localHash;
1171
915
  const remoteChanged = !!journalEntry && hasRemoteChanged(remoteMeta, journalEntry);
@@ -1188,7 +932,6 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
1188
932
  updateEntry(run.journal, relativePath, localHash, lstat.size, "up", absolutePath, {
1189
933
  remoteEtag: remoteMeta.etag,
1190
934
  mtimeMs: lstat.mtimeMs,
1191
- ctimeMs: ctimeToStamp(item.kind === "file" ? item.statAtHash : undefined, lstat),
1192
935
  kind: item.kind,
1193
936
  });
1194
937
  // Same checkpoint contract as an actual upload. After repeated
@@ -1202,8 +945,8 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
1202
945
  counters.filesSkipped++;
1203
946
  return;
1204
947
  }
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
948
+ // `localDiverges` means a prior pull-keep stamped the remote etag for
949
+ // re-fire silence (#137) while local never matched that remote. The
1207
950
  // journal-baseline If-Match fence alone would SUCCEED when remote is
1208
951
  // still at that etag, silently promoting the divergent local to write
1209
952
  // authority. Route through conflict (keep/abort/overwrite) instead.
@@ -1230,52 +973,18 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
1230
973
  abortFlightConflictPaths = [...conflictPaths];
1231
974
  return;
1232
975
  }
1233
- if (resolution === "keep") {
1234
- await writePushConflictMirror(run, item, normalizeEtag(remoteMeta.etag), "local");
1235
- counters.filesSkipped++;
1236
- return;
1237
- }
1238
- if (resolution === "skip") {
976
+ if (resolution === "keep" || resolution === "skip") {
1239
977
  if (isFreshCollision) {
1240
- await writePushConflictMirror(run, item, normalizeEtag(remoteMeta.etag), "remote");
978
+ await writePushConflictMirror(run, item, normalizeEtag(remoteMeta.etag));
1241
979
  }
1242
980
  counters.filesSkipped++;
1243
981
  return;
1244
982
  }
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.
983
+ // overwrite: fall through to conditional PUT (or unfenced retry on 412).
1265
984
  }
1266
985
  }
1267
986
  if (aborted)
1268
987
  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
- }
1279
988
  // Write fence: when remote exists, prefer last-synced journal remoteEtag
1280
989
  // over live HEAD so a peer-advanced object fails closed (412 → keep/
1281
990
  // abort/overwrite). Live-HEAD If-Match alone only covers HEAD→PUT TOCTOU.
@@ -1285,22 +994,19 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
1285
994
  // If-Match against a missing object cannot succeed and false-conflicts.
1286
995
  // Legacy entries without remoteEtag fall back to live HEAD (prior behavior).
1287
996
  const precondition = remoteMeta
1288
- ? fenceOnObservedRemote
1289
- ? { ifMatch: normalizeEtag(remoteMeta.etag) }
1290
- : journalEntry?.remoteEtag
1291
- ? { ifMatch: journalEntry.remoteEtag }
1292
- : { ifMatch: remoteMeta.etag }
997
+ ? journalEntry?.remoteEtag
998
+ ? { ifMatch: journalEntry.remoteEtag }
999
+ : { ifMatch: remoteMeta.etag }
1293
1000
  : { ifNoneMatch: "*" };
1294
1001
  const performUpload = async (pc) => {
1295
1002
  const isSymlinkUpload = item.kind === "symlink";
1296
1003
  const lstat = fs.lstatSync(absolutePath);
1297
1004
  const size = isSymlinkUpload ? 0 : lstat.size;
1298
1005
  const mtimeMs = lstat.mtimeMs;
1299
- const ctimeMs = ctimeToStamp(item.kind === "file" ? item.statAtHash : undefined, lstat);
1300
1006
  const { etag } = isSymlinkUpload
1301
1007
  ? await uploadSymlink(run.ctx, item.target, relativePath, run.options.author, pc)
1302
1008
  : await uploadFile(run.ctx, absolutePath, relativePath, run.options.author, pc);
1303
- updateEntry(run.journal, relativePath, localHash, size, "up", absolutePath, { remoteEtag: etag, mtimeMs, ctimeMs, kind: item.kind });
1009
+ updateEntry(run.journal, relativePath, localHash, size, "up", absolutePath, { remoteEtag: etag, mtimeMs, kind: item.kind });
1304
1010
  if (run.message) {
1305
1011
  run.journal.files[relativePath] = {
1306
1012
  ...run.journal.files[relativePath],
@@ -1349,22 +1055,6 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
1349
1055
  abortFlightConflictPaths = [...conflictPaths];
1350
1056
  return;
1351
1057
  }
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
- }
1368
1058
  if (resolution === "overwrite") {
1369
1059
  try {
1370
1060
  await performUpload(undefined);
@@ -1381,7 +1071,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
1381
1071
  }
1382
1072
  return;
1383
1073
  }
1384
- await writePushConflictMirror(run, item, remoteMeta ? normalizeEtag(remoteMeta.etag) : "", resolution === "keep" ? "local" : "remote");
1074
+ await writePushConflictMirror(run, item, remoteMeta ? normalizeEtag(remoteMeta.etag) : "");
1385
1075
  counters.filesSkipped++;
1386
1076
  return;
1387
1077
  }
@@ -1407,35 +1097,19 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
1407
1097
  // unless nothing is in flight, so a single file larger than the entire
1408
1098
  // budget still makes progress and the pool never deadlocks.
1409
1099
  const byteBudget = resolveUploadByteBudget(run.hostLoadAtStart.availableMemFraction);
1410
- const queue = [...pushPlan.items];
1100
+ const queue = [...uploadItems];
1411
1101
  const inFlight = new Set();
1412
1102
  let inFlightBytes = 0;
1413
1103
  while (queue.length > 0 || inFlight.size > 0) {
1414
1104
  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
- }
1431
1105
  // Reserve the file's CURRENT on-disk size, not the planned size: an
1432
1106
  // active log/session file can grow between planning and upload, and
1433
1107
  // uploadFile buffers the whole current body. A stale planned size would
1434
1108
  // let several "small" files all be admitted and then blow the budget.
1435
- const nextBytes = currentUploadBytes(item);
1109
+ const nextBytes = currentUploadBytes(queue[0]);
1436
1110
  if (inFlight.size > 0 && inFlightBytes + nextBytes > byteBudget)
1437
1111
  break;
1438
- queue.shift();
1112
+ const item = queue.shift();
1439
1113
  inFlightBytes += nextBytes;
1440
1114
  const p = processUploadItem(item)
1441
1115
  .catch((err) => {
@@ -1462,17 +1136,13 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
1462
1136
  }
1463
1137
  return { aborted, abortFlightConflictPaths, workerErrors, hadUploadFailure };
1464
1138
  }
1465
- async function writePushConflictMirror(run, item, remoteHash, preserved) {
1466
- let localMovedAside = false;
1467
- let remoteAdopted = false;
1468
- let conflictAbs;
1469
- let effectiveRemoteHash = remoteHash;
1139
+ async function writePushConflictMirror(run, item, remoteHash) {
1470
1140
  try {
1471
1141
  const detectedAt = new Date().toISOString();
1472
1142
  const machineId = readShortMachineId(run.hqRoot);
1473
1143
  const originalRelative = vaultKeyForLocalPath(run.hqRoot, item.absolutePath);
1474
1144
  const conflictRelative = buildConflictPath(originalRelative, detectedAt, machineId);
1475
- conflictAbs = localPathForVaultKey(run.hqRoot, conflictRelative);
1145
+ const conflictAbs = localPathForVaultKey(run.hqRoot, conflictRelative);
1476
1146
  if (!isMaterializationPathStillContained(run.syncRoot, conflictAbs)) {
1477
1147
  run.emit({
1478
1148
  type: "error",
@@ -1481,42 +1151,7 @@ async function writePushConflictMirror(run, item, remoteHash, preserved) {
1481
1151
  });
1482
1152
  }
1483
1153
  else {
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
- }
1154
+ await downloadFile(run.ctx, item.relativePath, conflictAbs);
1520
1155
  appendConflictEntry(run.hqRoot, {
1521
1156
  id: buildConflictId(originalRelative, detectedAt),
1522
1157
  originalPath: originalRelative,
@@ -1525,40 +1160,13 @@ async function writePushConflictMirror(run, item, remoteHash, preserved) {
1525
1160
  side: "push",
1526
1161
  machineId,
1527
1162
  localHash: item.localHash,
1528
- remoteHash: effectiveRemoteHash,
1529
- preserved,
1163
+ remoteHash,
1530
1164
  });
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
- }
1537
1165
  }
1538
1166
  }
1539
1167
  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
- }
1558
1168
  if (mirrorErr instanceof VaultAuthError)
1559
1169
  throw mirrorErr;
1560
- if (mirrorErr instanceof JournalCheckpointError)
1561
- throw mirrorErr;
1562
1170
  if (mirrorErr instanceof WindowsSymlinkPrivilegeError) {
1563
1171
  // The remote symlink record cannot be materialized to a conflict mirror
1564
1172
  // on this unprivileged Windows host. Emit the deliberate skip (never