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