@indigoai-us/hq-cloud 6.16.58-alpha.3 → 6.16.59
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 +0 -14
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +89 -57
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.js +313 -56
- package/dist/bin/sync-runner-company.test.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts +3 -12
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +3 -65
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-planning.test.js +1 -172
- package/dist/bin/sync-runner-planning.test.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +2 -51
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +165 -409
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +2 -19
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +5 -40
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.test.js +0 -47
- package/dist/bin/sync-runner-watch-routes.test.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +13 -59
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +4 -48
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +229 -1499
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +0 -19
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +0 -55
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +1 -87
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +0 -8
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +8 -12
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +152 -302
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +126 -256
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts +34 -1
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +92 -21
- package/dist/cognito-auth.js.map +1 -1
- package/dist/context.d.ts +5 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +92 -12
- package/dist/context.js.map +1 -1
- package/dist/context.test.js +77 -2
- package/dist/context.test.js.map +1 -1
- package/dist/ignore.d.ts +10 -0
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +47 -5
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +30 -3
- package/dist/ignore.test.js.map +1 -1
- package/dist/journal-row-store.d.ts +5 -40
- package/dist/journal-row-store.d.ts.map +1 -1
- package/dist/journal-row-store.js +21 -240
- package/dist/journal-row-store.js.map +1 -1
- package/dist/journal-row-store.test.js +0 -54
- package/dist/journal-row-store.test.js.map +1 -1
- package/dist/journal-snapshot-v4.d.ts.map +1 -1
- package/dist/journal-snapshot-v4.js +6 -11
- package/dist/journal-snapshot-v4.js.map +1 -1
- package/dist/journal-store.test.js +7 -324
- package/dist/journal-store.test.js.map +1 -1
- package/dist/journal.d.ts +16 -51
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +79 -303
- package/dist/journal.js.map +1 -1
- package/dist/lib/exit-codes.d.ts +0 -15
- package/dist/lib/exit-codes.d.ts.map +1 -1
- package/dist/lib/exit-codes.js +0 -15
- package/dist/lib/exit-codes.js.map +1 -1
- package/dist/lib/net-errors.d.ts +12 -0
- package/dist/lib/net-errors.d.ts.map +1 -1
- package/dist/lib/net-errors.js +35 -0
- package/dist/lib/net-errors.js.map +1 -1
- package/dist/lib/net-errors.test.js +19 -1
- package/dist/lib/net-errors.test.js.map +1 -1
- package/dist/machine-auth.test.js +6 -4
- package/dist/machine-auth.test.js.map +1 -1
- package/dist/machine-token-cache-isolation.test.d.ts +24 -0
- package/dist/machine-token-cache-isolation.test.d.ts.map +1 -0
- package/dist/machine-token-cache-isolation.test.js +241 -0
- package/dist/machine-token-cache-isolation.test.js.map +1 -0
- package/dist/manifest/build-manifest-resident-parity.test.js +3 -3
- package/dist/manifest/build-manifest-resident-parity.test.js.map +1 -1
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +3 -10
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +0 -35
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/remote-pull.d.ts.map +1 -1
- package/dist/remote-pull.js +2 -9
- package/dist/remote-pull.js.map +1 -1
- package/dist/remote-pull.test.js +2 -59
- package/dist/remote-pull.test.js.map +1 -1
- package/dist/s3.d.ts +0 -1
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +2 -20
- package/dist/s3.js.map +1 -1
- package/dist/sync/area-ledger.d.ts +3 -27
- package/dist/sync/area-ledger.d.ts.map +1 -1
- package/dist/sync/area-ledger.js +39 -149
- package/dist/sync/area-ledger.js.map +1 -1
- package/dist/sync/area-ledger.test.js +5 -192
- package/dist/sync/area-ledger.test.js.map +1 -1
- package/dist/sync/parallel-sync-coordinator.test.js +0 -29
- package/dist/sync/parallel-sync-coordinator.test.js.map +1 -1
- package/dist/sync/realtime-telemetry.d.ts +1 -1
- package/dist/sync/realtime-telemetry.d.ts.map +1 -1
- package/dist/sync/realtime-telemetry.js +6 -14
- package/dist/sync/realtime-telemetry.js.map +1 -1
- package/dist/sync/realtime-telemetry.test.js +16 -136
- package/dist/sync/realtime-telemetry.test.js.map +1 -1
- package/dist/sync/state-store.d.ts +0 -6
- package/dist/sync/state-store.d.ts.map +1 -1
- package/dist/sync/state-store.js +2 -14
- package/dist/sync/state-store.js.map +1 -1
- package/dist/watcher.d.ts +0 -12
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +0 -131
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +0 -63
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -2
- package/dist/cli/onepass-pull-planner-regressions.test.d.ts +0 -2
- package/dist/cli/onepass-pull-planner-regressions.test.d.ts.map +0 -1
- package/dist/cli/onepass-pull-planner-regressions.test.js +0 -300
- package/dist/cli/onepass-pull-planner-regressions.test.js.map +0 -1
- package/dist/cli/onepass-pull-planner.test.d.ts +0 -2
- package/dist/cli/onepass-pull-planner.test.d.ts.map +0 -1
- package/dist/cli/onepass-pull-planner.test.js +0 -266
- package/dist/cli/onepass-pull-planner.test.js.map +0 -1
- package/dist/cli/streaming-pull-planner.bench.test.d.ts +0 -15
- package/dist/cli/streaming-pull-planner.bench.test.d.ts.map +0 -1
- package/dist/cli/streaming-pull-planner.bench.test.js +0 -273
- package/dist/cli/streaming-pull-planner.bench.test.js.map +0 -1
- package/dist/s3.list-cost.test.d.ts +0 -2
- package/dist/s3.list-cost.test.d.ts.map +0 -1
- package/dist/s3.list-cost.test.js +0 -51
- package/dist/s3.list-cost.test.js.map +0 -1
- package/dist/sync/remote-file-spool.d.ts +0 -16
- package/dist/sync/remote-file-spool.d.ts.map +0 -1
- package/dist/sync/remote-file-spool.js +0 -91
- package/dist/sync/remote-file-spool.js.map +0 -1
- package/dist/sync/remote-file-spool.test.d.ts +0 -2
- package/dist/sync/remote-file-spool.test.d.ts.map +0 -1
- package/dist/sync/remote-file-spool.test.js +0 -77
- package/dist/sync/remote-file-spool.test.js.map +0 -1
package/dist/cli/sync.js
CHANGED
|
@@ -6,18 +6,19 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from "fs";
|
|
8
8
|
import * as path from "path";
|
|
9
|
-
import { spoolRemoteFiles, collectRemoteAncestors } from "../sync/remote-file-spool.js";
|
|
10
9
|
import { VaultAuthError, VaultClient } from "../vault-client.js";
|
|
11
10
|
import { readlinkOrNull } from "../lib/readlink-safe.js";
|
|
12
11
|
import { describeError } from "../lib/describe-error.js";
|
|
13
12
|
import { isRetryableIdentityRefusal, isTerminalIdentityRefusal, } from "../cognito-auth.js";
|
|
13
|
+
import { RateLimitedError } from "../object-io.js";
|
|
14
|
+
import { isTransientHttpStatusError } from "../lib/net-errors.js";
|
|
14
15
|
import { emitCloudTelemetry, } from "../telemetry-events.js";
|
|
15
16
|
import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
|
|
16
17
|
import { ensureCompanyVaultTransport } from "../company-vault-transport.js";
|
|
17
18
|
import { createSyncProgressRecorder } from "../sync-progress.js";
|
|
18
19
|
import { canonicalVaultKeySpelling, localPathForVaultKey, vaultKeyForLocalPath, } from "../local-path-codec.js";
|
|
19
20
|
import { SKILLS_KEY_PREFIX } from "./doctor.js";
|
|
20
|
-
import { downloadFile,
|
|
21
|
+
import { downloadFile, listRemoteFiles, headRemoteFile, primeObjectTransport, toPosixKey, classifyVaultKey, DanglingSymlinkParentError, WindowsSymlinkPrivilegeError, WINDOWS_SYMLINK_PRIVILEGE_REMEDY, WindowsRenameBlockedError, WINDOWS_RENAME_BLOCKED_REMEDY, renameOrRaiseWindowsBlocked, } from "../s3.js";
|
|
21
22
|
import { readJournalForPass, writeJournal, getStateDir, openJournalStoreSession, buildJournalBaseline, journalDeltaFromBaseline, hashFile, hashSymlinkTarget, journalRows, journalEntryMatchesStat, updateEntry, removeEntry, getEntry, normalizeEtag, migrateToV2, gcTombstones, isTombstone, tombstoneEntry, lastPullRecord, appendPullRecord, generatePullId, PERSONAL_VAULT_JOURNAL_SLUG, migratePersonalVaultJournal, } from "../journal.js";
|
|
22
23
|
import { isGeneratedCoreMirrorKey, PERSONAL_VAULT_MANIFEST_KEY, } from "../personal-vault.js";
|
|
23
24
|
import { isPersonalVaultExcluded } from "../personal-vault-exclusions.js";
|
|
@@ -186,11 +187,6 @@ export function targetedJournalSessionCensusSizes() {
|
|
|
186
187
|
* dirty entries through a run-scoped journal writer.
|
|
187
188
|
*/
|
|
188
189
|
const DOWNLOAD_JOURNAL_CHECKPOINT_BATCH_SIZE = 10_000;
|
|
189
|
-
/**
|
|
190
|
-
* A full-pull hand-off unit. Every member has finished its transfer and the
|
|
191
|
-
* resulting journal delta is durable before realtime can acquire the writer.
|
|
192
|
-
*/
|
|
193
|
-
export const FULL_PULL_COOPERATIVE_CHECKPOINT_BATCH_SIZE = 500;
|
|
194
190
|
/**
|
|
195
191
|
* Server cap on files per `/v1/notify/file-added` report. The endpoint rejects
|
|
196
192
|
* an oversized batch wholesale, so the client MUST split a large report into
|
|
@@ -324,53 +320,36 @@ export async function sync(options) {
|
|
|
324
320
|
return withOperationLock(options.hqRoot, "sync", () => syncWithOperationLockHeld(options));
|
|
325
321
|
}
|
|
326
322
|
async function syncWithOperationLockHeld(options) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
await verifyPlannedJournalTombstones(run, plan);
|
|
358
|
-
executeJournalTombstoneDeletes(run, plan, counters);
|
|
359
|
-
emitScopeMaterializationGap(run, plan);
|
|
360
|
-
return finalizePullRun(run, plan, scopeRun, counters);
|
|
361
|
-
}
|
|
362
|
-
catch (error) {
|
|
363
|
-
if (error instanceof CooperativePullHandoff) {
|
|
364
|
-
carried = error.completed;
|
|
365
|
-
continue;
|
|
366
|
-
}
|
|
367
|
-
throw error;
|
|
368
|
-
}
|
|
369
|
-
finally {
|
|
370
|
-
// Release a pass-local lease even on conflict, transfer, or auth failure.
|
|
371
|
-
// The one bounded hot lease remains available only for its same vault.
|
|
372
|
-
run.releaseKeyedJournal?.();
|
|
373
|
-
}
|
|
323
|
+
const run = await buildPullContext(options);
|
|
324
|
+
try {
|
|
325
|
+
const plan = planPull(run);
|
|
326
|
+
emitPullPlan(run.emit, plan);
|
|
327
|
+
reportInvalidRemoteKeys(run, plan);
|
|
328
|
+
const scopeRun = run.keyedJournal
|
|
329
|
+
? emptyTargetedScopeRun(run)
|
|
330
|
+
: executeScopeShrink(run, planScopeShrink(run));
|
|
331
|
+
const counters = createPullCounters();
|
|
332
|
+
const transferConcurrency = resolveTransferConcurrency(run.hostLoadAtStart);
|
|
333
|
+
const conflictRun = await executeConflictExecutor(run, plan, scopeRun, counters);
|
|
334
|
+
if (conflictRun.abortResult) {
|
|
335
|
+
return conflictRun.abortResult;
|
|
336
|
+
}
|
|
337
|
+
await executeDownloadExecutor(run, conflictRun.downloadItems, transferConcurrency, counters);
|
|
338
|
+
// The following added-by and tombstone phases may still make authenticated
|
|
339
|
+
// vault calls. Make a completed sub-threshold download tail durable before
|
|
340
|
+
// they begin, so their failure cannot turn a successful bulk transfer into
|
|
341
|
+
// a full replay on the next pass.
|
|
342
|
+
flushDownloadedJournalTail(run);
|
|
343
|
+
await emitAndReportNewFiles(run, plan);
|
|
344
|
+
await verifyPlannedJournalTombstones(run, plan);
|
|
345
|
+
executeJournalTombstoneDeletes(run, plan, counters);
|
|
346
|
+
emitScopeMaterializationGap(run, plan);
|
|
347
|
+
return finalizePullRun(run, plan, scopeRun, counters);
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
// Release a pass-local lease even on conflict, transfer, or auth failure.
|
|
351
|
+
// The one bounded hot lease remains available only for its same vault.
|
|
352
|
+
run.releaseKeyedJournal?.();
|
|
374
353
|
}
|
|
375
354
|
}
|
|
376
355
|
async function buildPullContext(options) {
|
|
@@ -459,9 +438,8 @@ async function buildPullContext(options) {
|
|
|
459
438
|
? targetedJournalSession(journalSlug)
|
|
460
439
|
: undefined;
|
|
461
440
|
const keyedJournal = keyedJournalLease?.store;
|
|
462
|
-
let remoteFiles;
|
|
463
441
|
try {
|
|
464
|
-
remoteFiles = await listRemoteFilesForScope(ctx, syncMode, listedPrefixSet, requestedPullPrefixes.length > 0);
|
|
442
|
+
const remoteFiles = await listRemoteFilesForScope(ctx, syncMode, listedPrefixSet, requestedPullPrefixes.length > 0);
|
|
465
443
|
const fileTombstones = options.fileTombstones ??
|
|
466
444
|
await fetchFileTombstones(vaultConfig, options.personalMode === true
|
|
467
445
|
? { personal: true }
|
|
@@ -509,27 +487,43 @@ async function buildPullContext(options) {
|
|
|
509
487
|
catch (error) {
|
|
510
488
|
// `buildPullContext` can fail during LIST/freshness. Do not leave the
|
|
511
489
|
// leased projection marked in use when no PullRunContext is returned.
|
|
512
|
-
if (remoteFiles && isStreamingRemoteFileSource(remoteFiles))
|
|
513
|
-
remoteFiles.dispose();
|
|
514
490
|
keyedJournalLease?.release();
|
|
515
491
|
throw error;
|
|
516
492
|
}
|
|
517
493
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
494
|
+
/**
|
|
495
|
+
* List the remote objects the current pull can actually materialize.
|
|
496
|
+
*
|
|
497
|
+
* Historically this was ALWAYS a full-bucket `listRemoteFiles(ctx)` — even for
|
|
498
|
+
* a `shared`/`custom` membership whose effective scope is a handful of
|
|
499
|
+
* prefixes — so every pull materialized the entire remote listing (tens of
|
|
500
|
+
* thousands of RemoteFile objects on large vaults) only for `computePullPlan`
|
|
501
|
+
* to discard most of it as `skip-out-of-scope`. Scoped pulls now list ONLY the
|
|
502
|
+
* in-scope prefixes via `listRemoteForScope` (per-prefix ListObjectsV2 calls,
|
|
503
|
+
* bounded parallel, deduped), which bounds the listing's memory to the scope's
|
|
504
|
+
* actual footprint.
|
|
505
|
+
*
|
|
506
|
+
* `all`-mode (an unnarrowed full-vault pull, `listedPrefixSet` `[""]`) keeps
|
|
507
|
+
* the single broad listing: fanning `all` out by top-level prefix would need a
|
|
508
|
+
* delimiter LIST to discover the top-level names first and still union the
|
|
509
|
+
* same total object count in one plan, so it bounds nothing today. (A
|
|
510
|
+
* streaming planner is the real fix for `all` and is out of scope here.)
|
|
511
|
+
*
|
|
512
|
+
* An empty `listedPrefixSet` in a scoped mode means "nothing in scope" —
|
|
513
|
+
* issue zero LIST calls. Consumers already treat an empty listing correctly:
|
|
514
|
+
* the deletion sweep in `computePullPlan` only considers journal keys covered
|
|
515
|
+
* by the LISTED scope, so keys absent because they were never listed can never
|
|
516
|
+
* be mistaken for remote deletes.
|
|
517
|
+
*/
|
|
524
518
|
async function listRemoteFilesForScope(ctx, syncMode, listedPrefixSet, isTargetedPull) {
|
|
525
519
|
const entries = toScopePrefixEntries(listedPrefixSet);
|
|
526
520
|
if (entries.some((e) => e.prefix === ""))
|
|
527
|
-
return
|
|
521
|
+
return listRemoteFiles(ctx);
|
|
528
522
|
if (entries.length === 0) {
|
|
529
523
|
// `all` mode always carries `[""]`, so an empty set only occurs for a
|
|
530
524
|
// scoped mode (empty grants, or a targeted intersection that excluded
|
|
531
525
|
// everything). Defensive `all` branch: never silently narrow a full pull.
|
|
532
|
-
return syncMode === "all" ?
|
|
526
|
+
return syncMode === "all" ? listRemoteFiles(ctx) : [];
|
|
533
527
|
}
|
|
534
528
|
return listRemoteForScope({
|
|
535
529
|
ctx,
|
|
@@ -550,14 +544,8 @@ async function listRemoteFilesForScope(ctx, syncMode, listedPrefixSet, isTargete
|
|
|
550
544
|
},
|
|
551
545
|
});
|
|
552
546
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
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.propagateDeletePolicy ?? "owned-only", run.options.onHeartbeat, run.journalRowCount);
|
|
556
|
-
}
|
|
557
|
-
finally {
|
|
558
|
-
if (isStreamingRemoteFileSource(run.remoteFiles))
|
|
559
|
-
run.remoteFiles.dispose();
|
|
560
|
-
}
|
|
547
|
+
function planPull(run) {
|
|
548
|
+
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.propagateDeletePolicy ?? "owned-only", run.options.onHeartbeat, run.journalRowCount);
|
|
561
549
|
}
|
|
562
550
|
/** Build only the rows a targeted remote LIST can observe. */
|
|
563
551
|
function journalForTargetedPull(store, prefixes) {
|
|
@@ -670,55 +658,9 @@ function createPullCounters() {
|
|
|
670
658
|
conflictPaths: [],
|
|
671
659
|
changedPaths: [],
|
|
672
660
|
appliedUpserts: [],
|
|
673
|
-
newFiles: [],
|
|
674
661
|
hasRetryableOutcome: false,
|
|
675
662
|
};
|
|
676
663
|
}
|
|
677
|
-
/** Fold a previous attempt's completed work into a restarted run's counters. */
|
|
678
|
-
function seedCarriedPullProgress(counters, carried) {
|
|
679
|
-
if (!carried)
|
|
680
|
-
return;
|
|
681
|
-
counters.filesDownloaded += carried.filesDownloaded;
|
|
682
|
-
counters.bytesDownloaded += carried.bytesDownloaded;
|
|
683
|
-
counters.conflicts += carried.conflicts;
|
|
684
|
-
counters.filesTombstoned += carried.filesTombstoned;
|
|
685
|
-
counters.conflictPaths.push(...carried.conflictPaths);
|
|
686
|
-
counters.changedPaths.push(...carried.changedPaths);
|
|
687
|
-
counters.appliedUpserts.push(...carried.appliedUpserts);
|
|
688
|
-
if (carried.hasRetryableOutcome)
|
|
689
|
-
counters.hasRetryableOutcome = true;
|
|
690
|
-
}
|
|
691
|
-
/**
|
|
692
|
-
* Snapshot what survives the hand-off. The numeric and path axes already
|
|
693
|
-
* include everything carried into this attempt; `newFiles` is accumulated
|
|
694
|
-
* separately so it stays a record of pre-restart downloads only, and cannot
|
|
695
|
-
* double up with the current plan's own new files.
|
|
696
|
-
*/
|
|
697
|
-
function carryPullProgress(previous, counters) {
|
|
698
|
-
return {
|
|
699
|
-
filesDownloaded: counters.filesDownloaded,
|
|
700
|
-
bytesDownloaded: counters.bytesDownloaded,
|
|
701
|
-
conflicts: counters.conflicts,
|
|
702
|
-
conflictPaths: [...counters.conflictPaths],
|
|
703
|
-
filesTombstoned: counters.filesTombstoned,
|
|
704
|
-
changedPaths: [...counters.changedPaths],
|
|
705
|
-
appliedUpserts: [...counters.appliedUpserts],
|
|
706
|
-
newFiles: [...(previous?.newFiles ?? []), ...counters.newFiles],
|
|
707
|
-
hasRetryableOutcome: counters.hasRetryableOutcome,
|
|
708
|
-
};
|
|
709
|
-
}
|
|
710
|
-
/**
|
|
711
|
-
* Every new file this pull is responsible for reporting: the ones a previous
|
|
712
|
-
* attempt downloaded before handing off, then this plan's own. A carried path
|
|
713
|
-
* that the fresh plan also lists is reported once.
|
|
714
|
-
*/
|
|
715
|
-
function reportableNewFiles(run, plan) {
|
|
716
|
-
const carried = run.carriedProgress?.newFiles;
|
|
717
|
-
if (carried === undefined || carried.length === 0)
|
|
718
|
-
return plan.newFiles;
|
|
719
|
-
const planned = new Set(plan.newFiles.map((newFile) => newFile.path));
|
|
720
|
-
return [...carried.filter((newFile) => !planned.has(newFile.path)), ...plan.newFiles];
|
|
721
|
-
}
|
|
722
664
|
function planScopeShrink(run) {
|
|
723
665
|
const lastRecord = lastPullRecord(run.journal, run.ctx.uid);
|
|
724
666
|
const lastPrefixSet = lastRecord && lastRecord.prefixSet.length > 0
|
|
@@ -1179,7 +1121,6 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
|
|
|
1179
1121
|
if (resolution === "abort") {
|
|
1180
1122
|
run.emit({ type: "new-files", files: [] });
|
|
1181
1123
|
persistPullJournal(run);
|
|
1182
|
-
const abortedNewFiles = reportableNewFiles(run, plan);
|
|
1183
1124
|
return {
|
|
1184
1125
|
filesDownloaded: counters.filesDownloaded,
|
|
1185
1126
|
bytesDownloaded: counters.bytesDownloaded,
|
|
@@ -1187,8 +1128,8 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
|
|
|
1187
1128
|
conflicts: counters.conflicts,
|
|
1188
1129
|
conflictPaths: counters.conflictPaths,
|
|
1189
1130
|
aborted: true,
|
|
1190
|
-
newFiles:
|
|
1191
|
-
newFilesCount:
|
|
1131
|
+
newFiles: plan.newFiles,
|
|
1132
|
+
newFilesCount: plan.newFilesCount,
|
|
1192
1133
|
filesExcludedByPolicy: plan.filesExcludedByPolicy,
|
|
1193
1134
|
filesTombstoned: 0,
|
|
1194
1135
|
filesOutOfScope: counters.filesOutOfScope,
|
|
@@ -1314,21 +1255,10 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
|
|
|
1314
1255
|
discardProbe();
|
|
1315
1256
|
}
|
|
1316
1257
|
}
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
*/
|
|
1322
|
-
class CooperativePullHandoff extends Error {
|
|
1323
|
-
/** Completed work the restarted pull must keep reporting. */
|
|
1324
|
-
completed;
|
|
1325
|
-
constructor(completed) {
|
|
1326
|
-
super("full pull handed off to realtime work");
|
|
1327
|
-
this.name = "CooperativePullHandoff";
|
|
1328
|
-
this.completed = completed;
|
|
1329
|
-
}
|
|
1330
|
-
}
|
|
1331
|
-
async function executeDownloadQueue(run, downloadItems, transferConcurrency, counters, deferJournalCheckpoint) {
|
|
1258
|
+
async function executeDownloadExecutor(run, downloadItems, transferConcurrency, counters) {
|
|
1259
|
+
if (downloadItems.length === 0)
|
|
1260
|
+
return;
|
|
1261
|
+
await primeObjectTransport(run.ctx, "get", downloadItems.map((d) => d.remoteFile.key));
|
|
1332
1262
|
const queue = [...downloadItems];
|
|
1333
1263
|
const inFlight = new Set();
|
|
1334
1264
|
const workerErrors = [];
|
|
@@ -1343,7 +1273,7 @@ async function executeDownloadQueue(run, downloadItems, transferConcurrency, cou
|
|
|
1343
1273
|
inFlight.size < transferConcurrency &&
|
|
1344
1274
|
queue.length > 0) {
|
|
1345
1275
|
const downloadItem = queue.shift();
|
|
1346
|
-
const download = () => downloadOne(run, downloadItem, counters
|
|
1276
|
+
const download = () => downloadOne(run, downloadItem, counters);
|
|
1347
1277
|
const p = (run.options.transferSemaphore
|
|
1348
1278
|
? run.options.transferSemaphore.withPermit(download)
|
|
1349
1279
|
: download())
|
|
@@ -1367,44 +1297,16 @@ async function executeDownloadQueue(run, downloadItems, transferConcurrency, cou
|
|
|
1367
1297
|
break;
|
|
1368
1298
|
}
|
|
1369
1299
|
}
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
if (workerErrors.length > 1) {
|
|
1376
|
-
first.message = `${first.message} (and ${workerErrors.length - 1} more download-worker errors)`;
|
|
1377
|
-
}
|
|
1378
|
-
throw first;
|
|
1379
|
-
}
|
|
1380
|
-
async function executeDownloadExecutor(run, downloadItems, transferConcurrency, counters) {
|
|
1381
|
-
if (downloadItems.length === 0)
|
|
1382
|
-
return;
|
|
1383
|
-
await primeObjectTransport(run.ctx, "get", downloadItems.map((d) => d.remoteFile.key));
|
|
1384
|
-
// Ordinary and targeted pulls retain the existing 10k crash-recovery cadence.
|
|
1385
|
-
// Only a watch chore that supplied the cooperative seam pays for drained
|
|
1386
|
-
// units and can return its root lock to queued realtime work.
|
|
1387
|
-
if (!run.options.cooperativeCheckpoint) {
|
|
1388
|
-
const workerErrors = await executeDownloadQueue(run, downloadItems, transferConcurrency, counters, false);
|
|
1389
|
-
if (workerErrors.length > 0)
|
|
1390
|
-
throwDownloadWorkerErrors(run, workerErrors);
|
|
1391
|
-
return;
|
|
1392
|
-
}
|
|
1393
|
-
for (let start = 0; start < downloadItems.length; start += FULL_PULL_COOPERATIVE_CHECKPOINT_BATCH_SIZE) {
|
|
1394
|
-
const chunk = downloadItems.slice(start, start + FULL_PULL_COOPERATIVE_CHECKPOINT_BATCH_SIZE);
|
|
1395
|
-
const workerErrors = await executeDownloadQueue(run, chunk, transferConcurrency, counters, true);
|
|
1396
|
-
// `executeDownloadQueue` does not return until every member of this chunk
|
|
1397
|
-
// has settled. Persist before the callback so the suspended chore owns no
|
|
1398
|
-
// mutable pass state while realtime uses the root lock.
|
|
1399
|
-
flushDownloadedJournalTail(run);
|
|
1400
|
-
if (workerErrors.length > 0)
|
|
1401
|
-
throwDownloadWorkerErrors(run, workerErrors);
|
|
1402
|
-
if (await run.options.cooperativeCheckpoint() === true) {
|
|
1403
|
-
throw new CooperativePullHandoff(carryPullProgress(run.carriedProgress, counters));
|
|
1300
|
+
if (workerErrors.length > 0) {
|
|
1301
|
+
persistPullJournal(run);
|
|
1302
|
+
const first = workerErrors[0];
|
|
1303
|
+
if (workerErrors.length > 1) {
|
|
1304
|
+
first.message = `${first.message} (and ${workerErrors.length - 1} more download-worker errors)`;
|
|
1404
1305
|
}
|
|
1306
|
+
throw first;
|
|
1405
1307
|
}
|
|
1406
1308
|
}
|
|
1407
|
-
async function downloadOne(run, downloadItem, counters
|
|
1309
|
+
async function downloadOne(run, downloadItem, counters) {
|
|
1408
1310
|
const { remoteFile, localPath } = downloadItem;
|
|
1409
1311
|
await refreshRunContextIfExpiring(run);
|
|
1410
1312
|
if (!isDownloadWritePathStillContained(run.companyRoot, remoteFile.key, localPath)) {
|
|
@@ -1473,9 +1375,6 @@ async function downloadOne(run, downloadItem, counters, deferJournalCheckpoint =
|
|
|
1473
1375
|
counters.filesDownloaded++;
|
|
1474
1376
|
counters.bytesDownloaded += size;
|
|
1475
1377
|
counters.changedPaths.push(remoteFile.key);
|
|
1476
|
-
if (downloadItem.isNew) {
|
|
1477
|
-
counters.newFiles.push({ path: remoteFile.key, bytes: remoteFile.size });
|
|
1478
|
-
}
|
|
1479
1378
|
// The event-push emitter hashes regular file bodies. Symlink journal hashes
|
|
1480
1379
|
// describe link targets instead, and the startup baseline excludes them for
|
|
1481
1380
|
// the same reason, so only hand regular-file downloads to that ledger.
|
|
@@ -1564,8 +1463,7 @@ async function downloadOne(run, downloadItem, counters, deferJournalCheckpoint =
|
|
|
1564
1463
|
return;
|
|
1565
1464
|
}
|
|
1566
1465
|
run.downloadsSinceJournalCheckpoint++;
|
|
1567
|
-
if (
|
|
1568
|
-
run.downloadsSinceJournalCheckpoint >= DOWNLOAD_JOURNAL_CHECKPOINT_BATCH_SIZE) {
|
|
1466
|
+
if (run.downloadsSinceJournalCheckpoint >= DOWNLOAD_JOURNAL_CHECKPOINT_BATCH_SIZE) {
|
|
1569
1467
|
flushDownloadedJournalTail(run);
|
|
1570
1468
|
}
|
|
1571
1469
|
}
|
|
@@ -1598,19 +1496,15 @@ function persistPullJournal(run) {
|
|
|
1598
1496
|
}
|
|
1599
1497
|
async function emitAndReportNewFiles(run, plan) {
|
|
1600
1498
|
const enrichedNewFiles = [];
|
|
1601
|
-
// New files a cooperative hand-off already downloaded are reported here too:
|
|
1602
|
-
// the restarted plan cannot see them as new, so this is their only
|
|
1603
|
-
// notification.
|
|
1604
|
-
const newFiles = reportableNewFiles(run, plan);
|
|
1605
1499
|
// Batch-mint the GET presigns once (chunked, breaker-aware) so the per-file
|
|
1606
1500
|
// created-by HEADs below reuse the cache instead of each minting its own
|
|
1607
1501
|
// presign. Without this, a big catch-up pull (hundreds of new files) bursts
|
|
1608
1502
|
// the presign endpoint, trips the circuit breaker, and every enrichment HEAD
|
|
1609
1503
|
// then fails. Mirrors the tombstone HEAD-verify pre-prime.
|
|
1610
|
-
await primeObjectTransport(run.ctx, "get", newFiles.map((nf) => nf.path));
|
|
1504
|
+
await primeObjectTransport(run.ctx, "get", plan.newFiles.map((nf) => nf.path));
|
|
1611
1505
|
const HEAD_CONCURRENCY = 5;
|
|
1612
|
-
for (let i = 0; i < newFiles.length; i += HEAD_CONCURRENCY) {
|
|
1613
|
-
const batch = newFiles.slice(i, i + HEAD_CONCURRENCY);
|
|
1506
|
+
for (let i = 0; i < plan.newFiles.length; i += HEAD_CONCURRENCY) {
|
|
1507
|
+
const batch = plan.newFiles.slice(i, i + HEAD_CONCURRENCY);
|
|
1614
1508
|
const results = await Promise.all(batch.map(async (nf) => {
|
|
1615
1509
|
let addedBy = null;
|
|
1616
1510
|
try {
|
|
@@ -1668,11 +1562,31 @@ async function verifyPlannedJournalTombstones(run, plan) {
|
|
|
1668
1562
|
}
|
|
1669
1563
|
if (isAccessDenied(err))
|
|
1670
1564
|
return null;
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1565
|
+
const message = `tombstone HEAD verify failed (deferring): ${describeError(err)}`;
|
|
1566
|
+
// A vault 100/hr throttle (or 429/502/503/504) is a deferral the
|
|
1567
|
+
// next sync will retry. Emitting a hard `type: "error"` here used
|
|
1568
|
+
// to land the company in the runner's `errors[]` and force exit 2
|
|
1569
|
+
// (`degraded partial`) even though the tombstone was already
|
|
1570
|
+
// dropped from this pass. Reuse the existing diagnostic marker
|
|
1571
|
+
// so the company stays `complete` and the runner exits 0.
|
|
1572
|
+
const transient = err instanceof RateLimitedError ||
|
|
1573
|
+
(err instanceof Error && err.name === "RateLimited") ||
|
|
1574
|
+
isTransientHttpStatusError(err);
|
|
1575
|
+
if (transient) {
|
|
1576
|
+
run.emit({
|
|
1577
|
+
type: "error",
|
|
1578
|
+
diagnostic: true,
|
|
1579
|
+
path: tombstone.key,
|
|
1580
|
+
message,
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1583
|
+
else {
|
|
1584
|
+
run.emit({
|
|
1585
|
+
type: "error",
|
|
1586
|
+
path: tombstone.key,
|
|
1587
|
+
message,
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1676
1590
|
return null;
|
|
1677
1591
|
}
|
|
1678
1592
|
}));
|
|
@@ -1771,7 +1685,6 @@ function finalizePullRun(run, plan, scopeRun, counters) {
|
|
|
1771
1685
|
// best-effort: a post-sync refresh failure never fails the sync
|
|
1772
1686
|
}
|
|
1773
1687
|
}
|
|
1774
|
-
const newFiles = reportableNewFiles(run, plan);
|
|
1775
1688
|
const result = {
|
|
1776
1689
|
filesDownloaded: counters.filesDownloaded,
|
|
1777
1690
|
bytesDownloaded: counters.bytesDownloaded,
|
|
@@ -1779,8 +1692,8 @@ function finalizePullRun(run, plan, scopeRun, counters) {
|
|
|
1779
1692
|
conflicts: counters.conflicts,
|
|
1780
1693
|
conflictPaths: counters.conflictPaths,
|
|
1781
1694
|
aborted: false,
|
|
1782
|
-
newFiles,
|
|
1783
|
-
newFilesCount:
|
|
1695
|
+
newFiles: plan.newFiles,
|
|
1696
|
+
newFilesCount: plan.newFilesCount,
|
|
1784
1697
|
filesExcludedByPolicy: plan.filesExcludedByPolicy,
|
|
1785
1698
|
filesTombstoned: counters.filesTombstoned,
|
|
1786
1699
|
filesOutOfScope: counters.filesOutOfScope,
|
|
@@ -2009,8 +1922,7 @@ class LocalSnapshotChangedError extends Error {
|
|
|
2009
1922
|
* the real outcome of the upcoming Stage-2 execution rather than an
|
|
2010
1923
|
* upper-bound guess.
|
|
2011
1924
|
*
|
|
2012
|
-
*
|
|
2013
|
-
* source performs its paged classification LIST while it is consumed; the
|
|
1925
|
+
* Pure function: no S3 calls, no journal writes, no event emission. The
|
|
2014
1926
|
* caller (`sync()`) is responsible for emitting the resulting plan event
|
|
2015
1927
|
* before iterating `items`.
|
|
2016
1928
|
*/
|
|
@@ -2078,36 +1990,6 @@ excludePrefixes = [],
|
|
|
2078
1990
|
// callers preserve the pre-policy, watcher-intent-only behavior.
|
|
2079
1991
|
propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows(journal).size) {
|
|
2080
1992
|
const items = [];
|
|
2081
|
-
const streaming = isStreamingRemoteFileSource(remoteFiles);
|
|
2082
|
-
const listedFiles = isStreamingRemoteFileSource(remoteFiles) ? remoteFiles.files : remoteFiles;
|
|
2083
|
-
let preplannedFilesSkipped = 0;
|
|
2084
|
-
let preplannedFilesOutOfScope = 0;
|
|
2085
|
-
let preplannedFilesExcludedByPolicy = 0;
|
|
2086
|
-
if (streaming) {
|
|
2087
|
-
const retain = items.push.bind(items);
|
|
2088
|
-
items.push = (...next) => {
|
|
2089
|
-
for (const item of next) {
|
|
2090
|
-
// These actions still need the executor for an event or a state change.
|
|
2091
|
-
if (item.action === "download" ||
|
|
2092
|
-
item.action === "conflict" ||
|
|
2093
|
-
item.action === "tombstone-delete" ||
|
|
2094
|
-
item.action === "skip-junk-key-spelling" ||
|
|
2095
|
-
item.action === "skip-unreadable-link") {
|
|
2096
|
-
retain(item);
|
|
2097
|
-
}
|
|
2098
|
-
else if (item.action === "skip-out-of-scope") {
|
|
2099
|
-
preplannedFilesOutOfScope++;
|
|
2100
|
-
}
|
|
2101
|
-
else if (item.action === "skip-excluded-policy") {
|
|
2102
|
-
preplannedFilesExcludedByPolicy++;
|
|
2103
|
-
}
|
|
2104
|
-
else {
|
|
2105
|
-
preplannedFilesSkipped++;
|
|
2106
|
-
}
|
|
2107
|
-
}
|
|
2108
|
-
return items.length;
|
|
2109
|
-
};
|
|
2110
|
-
}
|
|
2111
1993
|
// Remote keys the server will never presign — collected so the caller can
|
|
2112
1994
|
// warn + emit telemetry without allowing one poison object to fail a pull.
|
|
2113
1995
|
const invalidRemoteKeys = [];
|
|
@@ -2123,35 +2005,8 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
|
|
|
2123
2005
|
// never gated by this map (see the guard below), so pre-existing families
|
|
2124
2006
|
// keep syncing untouched until the vault doctor (US-003) collapses them.
|
|
2125
2007
|
const journaledKeyByCanonical = new Map();
|
|
2126
|
-
// The delete sweep starts with journaled keys and clears them as the streamed
|
|
2127
|
-
// LIST proves each one present. This retains existing journal-key references
|
|
2128
|
-
// instead of a second, whole-vault population of long remote key strings.
|
|
2129
|
-
const unseenJournalKeys = new Set();
|
|
2130
|
-
// Most journal keys have already been normalized to POSIX and can be cleared
|
|
2131
|
-
// directly. Keep the legacy Windows spelling defense-in-depth without
|
|
2132
|
-
// imposing a second map entry on every current journal row.
|
|
2133
|
-
const legacyJournalKeysByPosix = new Map();
|
|
2134
|
-
// The junk-spelling delete guard needs all journal twins, while
|
|
2135
|
-
// `journaledKeyByCanonical` above deliberately retains only the preferred
|
|
2136
|
-
// representative for classification.
|
|
2137
|
-
const journaledSkillKeysByCanonical = new Map();
|
|
2138
2008
|
for (const journaledKey of journalRows(journal).keys()) {
|
|
2139
2009
|
onHeartbeat?.();
|
|
2140
|
-
const posixJournaledKey = toPosixKey(journaledKey);
|
|
2141
|
-
// A scoped LIST cannot prove anything about keys outside its prefixes.
|
|
2142
|
-
// Do not copy the entire vault's journal into its presence bookkeeping.
|
|
2143
|
-
const inListedScope = isCoveredByAny(posixJournaledKey, prefixSet);
|
|
2144
|
-
if (inListedScope)
|
|
2145
|
-
unseenJournalKeys.add(journaledKey);
|
|
2146
|
-
if (inListedScope && posixJournaledKey !== journaledKey) {
|
|
2147
|
-
const legacyKeys = legacyJournalKeysByPosix.get(posixJournaledKey);
|
|
2148
|
-
if (legacyKeys === undefined) {
|
|
2149
|
-
legacyJournalKeysByPosix.set(posixJournaledKey, [journaledKey]);
|
|
2150
|
-
}
|
|
2151
|
-
else {
|
|
2152
|
-
legacyKeys.push(journaledKey);
|
|
2153
|
-
}
|
|
2154
|
-
}
|
|
2155
2010
|
if (!journaledKey.startsWith(SKILLS_KEY_PREFIX))
|
|
2156
2011
|
continue;
|
|
2157
2012
|
const canonical = canonicalVaultKeySpelling(journaledKey);
|
|
@@ -2159,15 +2014,6 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
|
|
|
2159
2014
|
if (existing === undefined || journaledKey === canonical) {
|
|
2160
2015
|
journaledKeyByCanonical.set(canonical, journaledKey);
|
|
2161
2016
|
}
|
|
2162
|
-
if (!inListedScope)
|
|
2163
|
-
continue;
|
|
2164
|
-
const skillKeys = journaledSkillKeysByCanonical.get(canonical);
|
|
2165
|
-
if (skillKeys === undefined) {
|
|
2166
|
-
journaledSkillKeysByCanonical.set(canonical, [journaledKey]);
|
|
2167
|
-
}
|
|
2168
|
-
else {
|
|
2169
|
-
skillKeys.push(journaledKey);
|
|
2170
|
-
}
|
|
2171
2017
|
}
|
|
2172
2018
|
// Remote keys that ALSO appear as an ancestor of another remote key — i.e.
|
|
2173
2019
|
// keys that carry child objects under `${key}/…` in this same LIST. The
|
|
@@ -2177,40 +2023,22 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
|
|
|
2177
2023
|
// objects beneath it. A REAL directory, by contrast, only ever syncs as
|
|
2178
2024
|
// those child objects — never as a single object AT its own key. So a single
|
|
2179
2025
|
// remote object sitting at a key that also has children is unambiguously a
|
|
2180
|
-
// directory-overlay marker, not a regular file.
|
|
2181
|
-
//
|
|
2182
|
-
//
|
|
2183
|
-
//
|
|
2184
|
-
const remoteKeysWithChildren =
|
|
2185
|
-
|
|
2186
|
-
: collectRemoteAncestors(remoteFiles);
|
|
2187
|
-
// Both sources replay synchronously. for-await on a scoped array introduces
|
|
2188
|
-
// promise/microtask work per object even though no I/O is being awaited.
|
|
2189
|
-
for (const remoteFile of listedFiles) {
|
|
2026
|
+
// directory-overlay marker, not a regular file. Precomputed once (pure path
|
|
2027
|
+
// derivation, no I/O — the planner is synchronous) so the dir-vs-object
|
|
2028
|
+
// collision branch can tell an inert overlay marker apart from a genuine
|
|
2029
|
+
// file-vs-directory structural collision. feedback_d2082110.
|
|
2030
|
+
const remoteKeysWithChildren = new Set();
|
|
2031
|
+
for (const rf of remoteFiles) {
|
|
2190
2032
|
onHeartbeat?.();
|
|
2191
|
-
const
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
if (remoteFile.key === posixRemoteKey) {
|
|
2197
|
-
unseenJournalKeys.delete(remoteFile.key);
|
|
2198
|
-
for (const legacyKey of legacyJournalKeysByPosix.get(remoteFile.key) ?? []) {
|
|
2199
|
-
unseenJournalKeys.delete(legacyKey);
|
|
2200
|
-
}
|
|
2201
|
-
}
|
|
2202
|
-
if (remoteFile.key.startsWith(SKILLS_KEY_PREFIX)) {
|
|
2203
|
-
// A remote junk spelling means its canonical journal twin is still
|
|
2204
|
-
// remotely present. Keep that twin out of the delete sweep unless an
|
|
2205
|
-
// exact FILE_TOMBSTONE is authoritative, precisely matching the old
|
|
2206
|
-
// remoteCanonicalSkillKeys protection without retaining remote keys.
|
|
2207
|
-
const canonical = canonicalVaultKeySpelling(remoteFile.key);
|
|
2208
|
-
for (const journaledKey of journaledSkillKeysByCanonical.get(canonical) ?? []) {
|
|
2209
|
-
if (fileTombstones.get(toPosixKey(journaledKey)) === undefined) {
|
|
2210
|
-
unseenJournalKeys.delete(journaledKey);
|
|
2211
|
-
}
|
|
2212
|
-
}
|
|
2033
|
+
const key = toPosixKey(rf.key);
|
|
2034
|
+
let slash = key.indexOf("/");
|
|
2035
|
+
while (slash !== -1) {
|
|
2036
|
+
remoteKeysWithChildren.add(key.slice(0, slash));
|
|
2037
|
+
slash = key.indexOf("/", slash + 1);
|
|
2213
2038
|
}
|
|
2039
|
+
}
|
|
2040
|
+
for (const remoteFile of remoteFiles) {
|
|
2041
|
+
onHeartbeat?.();
|
|
2214
2042
|
const invalidKey = classifyVaultKey(remoteFile.key, personalMode ? "personal" : "company");
|
|
2215
2043
|
if (invalidKey !== null) {
|
|
2216
2044
|
invalidRemoteKeys.push({
|
|
@@ -2863,9 +2691,6 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
|
|
|
2863
2691
|
filesToSkip++;
|
|
2864
2692
|
}
|
|
2865
2693
|
}
|
|
2866
|
-
filesToSkip += preplannedFilesSkipped;
|
|
2867
|
-
filesExcludedByPolicy += preplannedFilesExcludedByPolicy;
|
|
2868
|
-
filesOutOfScope += preplannedFilesOutOfScope;
|
|
2869
2694
|
// Bug #9 — cross-machine delete propagation. The peer's push leg
|
|
2870
2695
|
// already removed the file from S3 (verified in the deep-test
|
|
2871
2696
|
// addendum: `aws s3 ls` showed the keys absent post-push). Pre-fix
|
|
@@ -2874,21 +2699,38 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
|
|
|
2874
2699
|
// that-was-there-before" — so the file stayed forever on every
|
|
2875
2700
|
// receiver and `filesTombstoned: 0` showed up on every pull.
|
|
2876
2701
|
//
|
|
2877
|
-
// Closing the loop: walk journal
|
|
2878
|
-
// whose path passes the current
|
|
2702
|
+
// Closing the loop: walk the journal, find every entry whose key is
|
|
2703
|
+
// NOT in the remote LIST set, AND whose path passes the current
|
|
2879
2704
|
// ignore filter (paths newly excluded by .hqignore must not trigger
|
|
2880
2705
|
// mass-delete), AND — in personalMode — survives the same companies/*
|
|
2881
2706
|
// gating the download branch applies. The executor will apply each
|
|
2882
2707
|
// as a local delete + journal removal. Symmetric to the push side's
|
|
2883
2708
|
// `propagateDeletes` plan in share.ts.
|
|
2709
|
+
const remoteKeySet = new Set();
|
|
2710
|
+
// Skill-farm twin protection (companion to the junk-spelling pull guard):
|
|
2711
|
+
// when the LIST holds only a junk spelling of a journaled canonical key,
|
|
2712
|
+
// the logical object is still remotely present — a raw has() would class
|
|
2713
|
+
// the canonical journal entry as remote-deleted, unlink the good local
|
|
2714
|
+
// wrapper, and drop the journal entry (after which the next pull would
|
|
2715
|
+
// materialize the junk spelling the guard just refused). Track canonical
|
|
2716
|
+
// forms of listed skill keys and consult them below.
|
|
2717
|
+
const remoteCanonicalSkillKeys = new Set();
|
|
2718
|
+
for (const rf of remoteFiles) {
|
|
2719
|
+
remoteKeySet.add(rf.key);
|
|
2720
|
+
if (rf.key.startsWith(SKILLS_KEY_PREFIX)) {
|
|
2721
|
+
remoteCanonicalSkillKeys.add(canonicalVaultKeySpelling(rf.key));
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2884
2724
|
const tombstones = [];
|
|
2885
|
-
for (const key of
|
|
2725
|
+
for (const key of journalRows(journal).keys()) {
|
|
2886
2726
|
// Compare membership in POSIX space. A pre-5.47.2 Windows journal key can
|
|
2887
2727
|
// carry backslash separators; the remote LIST is always forward-slash, so a
|
|
2888
2728
|
// raw `has(key)` would miss the match and class a live local file as
|
|
2889
2729
|
// remote-deleted. normalizeJournalKeys() defuses this on load, but the
|
|
2890
2730
|
// POSIX compare is defense-in-depth (ridge data-loss, feedback_b8d09d0f).
|
|
2891
2731
|
const posixKey = toPosixKey(key);
|
|
2732
|
+
if (remoteKeySet.has(posixKey))
|
|
2733
|
+
continue;
|
|
2892
2734
|
// Listing-scope guard (targeted sync / scoped listing). The remote LIST
|
|
2893
2735
|
// now covers ONLY `prefixSet` (scoped modes list per-prefix; a targeted
|
|
2894
2736
|
// pull narrows further), so "absent from the LIST" is evidence of a remote
|
|
@@ -2900,6 +2742,16 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
|
|
|
2900
2742
|
// `all` mode passes `[""]`, which covers every key — no behavior change.
|
|
2901
2743
|
if (!isCoveredByAny(posixKey, prefixSet))
|
|
2902
2744
|
continue;
|
|
2745
|
+
if (posixKey.startsWith(SKILLS_KEY_PREFIX) &&
|
|
2746
|
+
remoteCanonicalSkillKeys.has(canonicalVaultKeySpelling(posixKey)) &&
|
|
2747
|
+
// An EXACT FILE_TOMBSTONE for the journaled key is an authoritative
|
|
2748
|
+
// delete and wins over the twin protection — a lingering junk
|
|
2749
|
+
// spelling in the LIST must not keep a deliberately-deleted wrapper
|
|
2750
|
+
// alive forever. (The key is absent from the LIST here, so there is
|
|
2751
|
+
// no newer re-create to defer to.)
|
|
2752
|
+
fileTombstones.get(posixKey) === undefined) {
|
|
2753
|
+
continue;
|
|
2754
|
+
}
|
|
2903
2755
|
// Fast-path ignore, BEFORE containment canonicalization. The tombstone
|
|
2904
2756
|
// sweep runs for every journaled key, and a stale journal can carry
|
|
2905
2757
|
// hundreds of thousands of entries under an ignored runtime tree (e.g.
|
|
@@ -3008,8 +2860,6 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
|
|
|
3008
2860
|
}
|
|
3009
2861
|
return {
|
|
3010
2862
|
items,
|
|
3011
|
-
preplannedFilesSkipped,
|
|
3012
|
-
preplannedFilesOutOfScope,
|
|
3013
2863
|
filesToDownload,
|
|
3014
2864
|
bytesToDownload,
|
|
3015
2865
|
filesToSkip,
|