@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.
- package/dist/bandwidth.d.ts +128 -0
- package/dist/bandwidth.d.ts.map +1 -0
- package/dist/bandwidth.js +254 -0
- package/dist/bandwidth.js.map +1 -0
- package/dist/bandwidth.test.d.ts +2 -0
- package/dist/bandwidth.test.d.ts.map +1 -0
- package/dist/bandwidth.test.js +243 -0
- package/dist/bandwidth.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +13 -24
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.js +71 -0
- package/dist/bin/sync-runner-company.test.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +10 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +181 -16
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +16 -1
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +40 -2
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.test.js +53 -1
- package/dist/bin/sync-runner-watch-routes.test.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +15 -3
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +66 -13
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +433 -13
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/conflict.d.ts +25 -2
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +29 -4
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/share.d.ts +40 -4
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +478 -86
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +845 -150
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +147 -18
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +20 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +261 -29
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +519 -90
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal-delta-writes.test.d.ts +2 -0
- package/dist/journal-delta-writes.test.d.ts.map +1 -0
- package/dist/journal-delta-writes.test.js +140 -0
- package/dist/journal-delta-writes.test.js.map +1 -0
- package/dist/journal.d.ts +1 -0
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +155 -25
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +29 -11
- package/dist/journal.test.js.map +1 -1
- package/dist/object-io.d.ts +34 -13
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +154 -29
- package/dist/object-io.js.map +1 -1
- package/dist/operation-lock.d.ts +12 -0
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +103 -21
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +90 -0
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/outcome-telemetry.d.ts +7 -1
- package/dist/outcome-telemetry.d.ts.map +1 -1
- package/dist/outcome-telemetry.js +15 -9
- package/dist/outcome-telemetry.js.map +1 -1
- package/dist/outcome-telemetry.test.js +15 -0
- package/dist/outcome-telemetry.test.js.map +1 -1
- package/dist/outposts/client.d.ts +2 -2
- package/dist/outposts/client.d.ts.map +1 -1
- package/dist/outposts/client.js +4 -3
- package/dist/outposts/client.js.map +1 -1
- package/dist/outposts/command.d.ts.map +1 -1
- package/dist/outposts/command.js +9 -6
- package/dist/outposts/command.js.map +1 -1
- package/dist/outposts/command.test.js +59 -2
- package/dist/outposts/command.test.js.map +1 -1
- package/dist/outposts/remote-command.d.ts +8 -8
- package/dist/outposts/remote-command.d.ts.map +1 -1
- package/dist/outposts/remote-command.js +10 -8
- package/dist/outposts/remote-command.js.map +1 -1
- package/dist/prefix-coalesce.d.ts +14 -0
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +29 -0
- package/dist/prefix-coalesce.js.map +1 -1
- package/dist/prefix-coalesce.test.js +22 -1
- package/dist/prefix-coalesce.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts +6 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +14 -8
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +43 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sts-credential-refresh.test.js +87 -2
- package/dist/sts-credential-refresh.test.js.map +1 -1
- package/dist/sync/state-store.d.ts.map +1 -1
- package/dist/sync/state-store.js +20 -1
- package/dist/sync/state-store.js.map +1 -1
- package/dist/sync/state-store.test.js +64 -0
- package/dist/sync/state-store.test.js.map +1 -1
- package/dist/sync-core.d.ts.map +1 -1
- package/dist/sync-core.js +7 -2
- package/dist/sync-core.js.map +1 -1
- package/dist/sync-core.test.js +36 -0
- package/dist/sync-core.test.js.map +1 -1
- package/dist/telemetry.d.ts +30 -6
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +331 -135
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +403 -0
- package/dist/telemetry.test.js.map +1 -1
- package/dist/types.d.ts +26 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/watcher.d.ts +76 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +105 -3
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +165 -1
- package/dist/watcher.test.js.map +1 -1
- 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-
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
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
|
|
247
|
-
*
|
|
248
|
-
*
|
|
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
|
-
|
|
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
|
|
288
|
-
//
|
|
289
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
|
809
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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-
|
|
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"
|
|
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
|
-
//
|
|
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
|
-
?
|
|
998
|
-
? { ifMatch:
|
|
999
|
-
:
|
|
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 = [...
|
|
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(
|
|
1435
|
+
const nextBytes = currentUploadBytes(item);
|
|
1110
1436
|
if (inFlight.size > 0 && inFlightBytes + nextBytes > byteBudget)
|
|
1111
1437
|
break;
|
|
1112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|