@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.
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +24 -13
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.js +0 -71
- package/dist/bin/sync-runner-company.test.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +0 -10
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +13 -170
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +1 -16
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +2 -40
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.test.js +1 -53
- package/dist/bin/sync-runner-watch-routes.test.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +3 -15
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +13 -66
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +13 -433
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/conflict.d.ts +2 -25
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +4 -29
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/share.d.ts +4 -40
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +86 -478
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +150 -845
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +18 -147
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +0 -20
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +29 -261
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +90 -519
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +0 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +25 -155
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +11 -29
- package/dist/journal.test.js.map +1 -1
- package/dist/object-io.d.ts +13 -34
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +29 -154
- package/dist/object-io.js.map +1 -1
- package/dist/outcome-telemetry.d.ts +1 -7
- package/dist/outcome-telemetry.d.ts.map +1 -1
- package/dist/outcome-telemetry.js +9 -15
- package/dist/outcome-telemetry.js.map +1 -1
- package/dist/outcome-telemetry.test.js +0 -15
- 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 +3 -4
- package/dist/outposts/client.js.map +1 -1
- package/dist/outposts/command.d.ts.map +1 -1
- package/dist/outposts/command.js +6 -9
- package/dist/outposts/command.js.map +1 -1
- package/dist/outposts/command.test.js +2 -59
- 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 +8 -10
- package/dist/outposts/remote-command.js.map +1 -1
- package/dist/prefix-coalesce.d.ts +0 -14
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +0 -29
- package/dist/prefix-coalesce.js.map +1 -1
- package/dist/prefix-coalesce.test.js +1 -22
- package/dist/prefix-coalesce.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts +1 -6
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +8 -14
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +0 -43
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sts-credential-refresh.test.js +2 -87
- 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 +1 -20
- package/dist/sync/state-store.js.map +1 -1
- package/dist/sync/state-store.test.js +0 -64
- package/dist/sync/state-store.test.js.map +1 -1
- package/dist/sync-core.d.ts.map +1 -1
- package/dist/sync-core.js +2 -7
- package/dist/sync-core.js.map +1 -1
- package/dist/sync-core.test.js +0 -36
- package/dist/sync-core.test.js.map +1 -1
- package/dist/telemetry.d.ts +6 -30
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +135 -331
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +0 -403
- package/dist/telemetry.test.js.map +1 -1
- package/dist/types.d.ts +3 -26
- package/dist/types.d.ts.map +1 -1
- package/dist/watcher.d.ts +0 -76
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +3 -105
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +1 -165
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/dist/bandwidth.d.ts +0 -128
- package/dist/bandwidth.d.ts.map +0 -1
- package/dist/bandwidth.js +0 -254
- package/dist/bandwidth.js.map +0 -1
- package/dist/bandwidth.test.d.ts +0 -2
- package/dist/bandwidth.test.d.ts.map +0 -1
- package/dist/bandwidth.test.js +0 -243
- package/dist/bandwidth.test.js.map +0 -1
- package/dist/journal-delta-writes.test.d.ts +0 -2
- package/dist/journal-delta-writes.test.d.ts.map +0 -1
- package/dist/journal-delta-writes.test.js +0 -140
- 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-
|
|
80
|
-
* 3-way merge
|
|
81
|
-
*
|
|
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
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
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
|
|
308
|
-
*
|
|
309
|
-
*
|
|
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
|
-
|
|
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
|
|
384
|
-
//
|
|
385
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
existing.size !==
|
|
435
|
-
|
|
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
|
|
983
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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-
|
|
1206
|
-
//
|
|
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)
|
|
978
|
+
await writePushConflictMirror(run, item, normalizeEtag(remoteMeta.etag));
|
|
1241
979
|
}
|
|
1242
980
|
counters.filesSkipped++;
|
|
1243
981
|
return;
|
|
1244
982
|
}
|
|
1245
|
-
//
|
|
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
|
-
?
|
|
1289
|
-
? { ifMatch:
|
|
1290
|
-
:
|
|
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,
|
|
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) : ""
|
|
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 = [...
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|