@indigoai-us/hq-cloud 6.16.64 → 6.16.66
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 +14 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +42 -8
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.js +62 -7
- package/dist/bin/sync-runner-company.test.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts +12 -3
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +65 -3
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-planning.test.js +172 -1
- package/dist/bin/sync-runner-planning.test.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +52 -3
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +443 -167
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +19 -2
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +40 -5
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.test.js +47 -0
- package/dist/bin/sync-runner-watch-routes.test.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +64 -6
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +48 -4
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +1577 -169
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/onepass-pull-planner-regressions.test.d.ts +2 -0
- package/dist/cli/onepass-pull-planner-regressions.test.d.ts.map +1 -0
- package/dist/cli/onepass-pull-planner-regressions.test.js +300 -0
- package/dist/cli/onepass-pull-planner-regressions.test.js.map +1 -0
- package/dist/cli/onepass-pull-planner.test.d.ts +2 -0
- package/dist/cli/onepass-pull-planner.test.d.ts.map +1 -0
- package/dist/cli/onepass-pull-planner.test.js +266 -0
- package/dist/cli/onepass-pull-planner.test.js.map +1 -0
- package/dist/cli/push-scan-exclusions.test.js +4 -4
- package/dist/cli/push-scan-exclusions.test.js.map +1 -1
- package/dist/cli/share.d.ts +31 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +136 -6
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +295 -5
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/streaming-pull-planner.bench.test.d.ts +15 -0
- package/dist/cli/streaming-pull-planner.bench.test.d.ts.map +1 -0
- package/dist/cli/streaming-pull-planner.bench.test.js +273 -0
- package/dist/cli/streaming-pull-planner.bench.test.js.map +1 -0
- package/dist/cli/sync-scope.test.js +8 -0
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +12 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +302 -123
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +255 -2
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/hq-root-recovery.d.ts +30 -0
- package/dist/hq-root-recovery.d.ts.map +1 -0
- package/dist/hq-root-recovery.js +131 -0
- package/dist/hq-root-recovery.js.map +1 -0
- package/dist/hq-root-recovery.test.d.ts +2 -0
- package/dist/hq-root-recovery.test.d.ts.map +1 -0
- package/dist/hq-root-recovery.test.js +65 -0
- package/dist/hq-root-recovery.test.js.map +1 -0
- package/dist/journal-row-store.d.ts +40 -5
- package/dist/journal-row-store.d.ts.map +1 -1
- package/dist/journal-row-store.js +240 -21
- package/dist/journal-row-store.js.map +1 -1
- package/dist/journal-row-store.test.js +54 -0
- 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 +11 -6
- package/dist/journal-snapshot-v4.js.map +1 -1
- package/dist/journal-store.test.js +324 -7
- package/dist/journal-store.test.js.map +1 -1
- package/dist/journal.d.ts +51 -16
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +303 -79
- package/dist/journal.js.map +1 -1
- package/dist/lib/exit-codes.d.ts +15 -0
- package/dist/lib/exit-codes.d.ts.map +1 -1
- package/dist/lib/exit-codes.js +15 -0
- package/dist/lib/exit-codes.js.map +1 -1
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +10 -3
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +35 -0
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/remote-pull.d.ts.map +1 -1
- package/dist/remote-pull.js +9 -2
- package/dist/remote-pull.js.map +1 -1
- package/dist/remote-pull.test.js +59 -2
- package/dist/remote-pull.test.js.map +1 -1
- package/dist/s3.d.ts +1 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +20 -2
- package/dist/s3.js.map +1 -1
- package/dist/s3.list-cost.test.d.ts +2 -0
- package/dist/s3.list-cost.test.d.ts.map +1 -0
- package/dist/s3.list-cost.test.js +51 -0
- package/dist/s3.list-cost.test.js.map +1 -0
- package/dist/sync/area-ledger.d.ts +27 -3
- package/dist/sync/area-ledger.d.ts.map +1 -1
- package/dist/sync/area-ledger.js +149 -39
- package/dist/sync/area-ledger.js.map +1 -1
- package/dist/sync/area-ledger.test.js +192 -5
- package/dist/sync/area-ledger.test.js.map +1 -1
- package/dist/sync/parallel-sync-coordinator.test.js +29 -0
- 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 +14 -6
- package/dist/sync/realtime-telemetry.js.map +1 -1
- package/dist/sync/realtime-telemetry.test.js +136 -16
- package/dist/sync/realtime-telemetry.test.js.map +1 -1
- package/dist/sync/remote-file-spool.d.ts +16 -0
- package/dist/sync/remote-file-spool.d.ts.map +1 -0
- package/dist/sync/remote-file-spool.js +91 -0
- package/dist/sync/remote-file-spool.js.map +1 -0
- package/dist/sync/remote-file-spool.test.d.ts +2 -0
- package/dist/sync/remote-file-spool.test.d.ts.map +1 -0
- package/dist/sync/remote-file-spool.test.js +77 -0
- package/dist/sync/remote-file-spool.test.js.map +1 -0
- package/dist/sync/state-store.d.ts +6 -0
- package/dist/sync/state-store.d.ts.map +1 -1
- package/dist/sync/state-store.js +14 -2
- package/dist/sync/state-store.js.map +1 -1
- package/dist/watch-coverage.d.ts +24 -0
- package/dist/watch-coverage.d.ts.map +1 -0
- package/dist/watch-coverage.js +46 -0
- package/dist/watch-coverage.js.map +1 -0
- package/dist/watch-coverage.test.d.ts +2 -0
- package/dist/watch-coverage.test.d.ts.map +1 -0
- package/dist/watch-coverage.test.js +56 -0
- package/dist/watch-coverage.test.js.map +1 -0
- package/dist/watcher.d.ts +31 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +172 -0
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +78 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +2 -1
package/dist/cli/sync.js
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
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";
|
|
9
10
|
import { VaultAuthError, VaultClient } from "../vault-client.js";
|
|
10
11
|
import { readlinkOrNull } from "../lib/readlink-safe.js";
|
|
11
12
|
import { describeError } from "../lib/describe-error.js";
|
|
12
13
|
import { isRetryableIdentityRefusal, isTerminalIdentityRefusal, } from "../cognito-auth.js";
|
|
13
14
|
import { RateLimitedError } from "../object-io.js";
|
|
15
|
+
import { recoverHqRootLocalState } from "../hq-root-recovery.js";
|
|
14
16
|
import { isTransientHttpStatusError } from "../lib/net-errors.js";
|
|
15
17
|
import { emitCloudTelemetry, } from "../telemetry-events.js";
|
|
16
18
|
import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
|
|
@@ -18,7 +20,7 @@ import { ensureCompanyVaultTransport } from "../company-vault-transport.js";
|
|
|
18
20
|
import { createSyncProgressRecorder } from "../sync-progress.js";
|
|
19
21
|
import { canonicalVaultKeySpelling, localPathForVaultKey, vaultKeyForLocalPath, } from "../local-path-codec.js";
|
|
20
22
|
import { SKILLS_KEY_PREFIX } from "./doctor.js";
|
|
21
|
-
import { downloadFile,
|
|
23
|
+
import { downloadFile, streamRemoteFiles, headRemoteFile, primeObjectTransport, toPosixKey, classifyVaultKey, DanglingSymlinkParentError, WindowsSymlinkPrivilegeError, WINDOWS_SYMLINK_PRIVILEGE_REMEDY, WindowsRenameBlockedError, WINDOWS_RENAME_BLOCKED_REMEDY, renameOrRaiseWindowsBlocked, } from "../s3.js";
|
|
22
24
|
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";
|
|
23
25
|
import { isGeneratedCoreMirrorKey, PERSONAL_VAULT_MANIFEST_KEY, } from "../personal-vault.js";
|
|
24
26
|
import { isPersonalVaultExcluded } from "../personal-vault-exclusions.js";
|
|
@@ -187,6 +189,11 @@ export function targetedJournalSessionCensusSizes() {
|
|
|
187
189
|
* dirty entries through a run-scoped journal writer.
|
|
188
190
|
*/
|
|
189
191
|
const DOWNLOAD_JOURNAL_CHECKPOINT_BATCH_SIZE = 10_000;
|
|
192
|
+
/**
|
|
193
|
+
* A full-pull hand-off unit. Every member has finished its transfer and the
|
|
194
|
+
* resulting journal delta is durable before realtime can acquire the writer.
|
|
195
|
+
*/
|
|
196
|
+
export const FULL_PULL_COOPERATIVE_CHECKPOINT_BATCH_SIZE = 500;
|
|
190
197
|
/**
|
|
191
198
|
* Server cap on files per `/v1/notify/file-added` report. The endpoint rejects
|
|
192
199
|
* an oversized batch wholesale, so the client MUST split a large report into
|
|
@@ -320,36 +327,59 @@ export async function sync(options) {
|
|
|
320
327
|
return withOperationLock(options.hqRoot, "sync", () => syncWithOperationLockHeld(options));
|
|
321
328
|
}
|
|
322
329
|
async function syncWithOperationLockHeld(options) {
|
|
323
|
-
const run = await buildPullContext(options);
|
|
324
330
|
try {
|
|
325
|
-
|
|
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);
|
|
331
|
+
recoverHqRootLocalState(options.hqRoot);
|
|
348
332
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
333
|
+
catch (err) {
|
|
334
|
+
console.warn("hq-root-recovery: local HQ recovery failed:", err);
|
|
335
|
+
}
|
|
336
|
+
let carried;
|
|
337
|
+
for (;;) {
|
|
338
|
+
const run = await buildPullContext(options);
|
|
339
|
+
if (carried)
|
|
340
|
+
run.carriedProgress = carried;
|
|
341
|
+
try {
|
|
342
|
+
const plan = await planPull(run);
|
|
343
|
+
emitPullPlan(run.emit, plan);
|
|
344
|
+
reportInvalidRemoteKeys(run, plan);
|
|
345
|
+
const scopeRun = run.keyedJournal
|
|
346
|
+
? emptyTargetedScopeRun(run)
|
|
347
|
+
: executeScopeShrink(run, planScopeShrink(run));
|
|
348
|
+
const counters = createPullCounters();
|
|
349
|
+
// A streaming all-mode planner retains only executor-relevant items. Its
|
|
350
|
+
// ordinary skips have already been counted while the page was live.
|
|
351
|
+
counters.filesSkipped = plan.preplannedFilesSkipped;
|
|
352
|
+
counters.filesOutOfScope = plan.preplannedFilesOutOfScope;
|
|
353
|
+
seedCarriedPullProgress(counters, carried);
|
|
354
|
+
const transferConcurrency = resolveTransferConcurrency(run.hostLoadAtStart);
|
|
355
|
+
const conflictRun = await executeConflictExecutor(run, plan, scopeRun, counters);
|
|
356
|
+
if (conflictRun.abortResult) {
|
|
357
|
+
return conflictRun.abortResult;
|
|
358
|
+
}
|
|
359
|
+
await executeDownloadExecutor(run, conflictRun.downloadItems, transferConcurrency, counters);
|
|
360
|
+
// The following added-by and tombstone phases may still make authenticated
|
|
361
|
+
// vault calls. Make a completed sub-threshold download tail durable before
|
|
362
|
+
// they begin, so their failure cannot turn a successful bulk transfer into
|
|
363
|
+
// a full replay on the next pass.
|
|
364
|
+
flushDownloadedJournalTail(run);
|
|
365
|
+
await emitAndReportNewFiles(run, plan);
|
|
366
|
+
await verifyPlannedJournalTombstones(run, plan);
|
|
367
|
+
executeJournalTombstoneDeletes(run, plan, counters);
|
|
368
|
+
emitScopeMaterializationGap(run, plan);
|
|
369
|
+
return finalizePullRun(run, plan, scopeRun, counters);
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
if (error instanceof CooperativePullHandoff) {
|
|
373
|
+
carried = error.completed;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
finally {
|
|
379
|
+
// Release a pass-local lease even on conflict, transfer, or auth failure.
|
|
380
|
+
// The one bounded hot lease remains available only for its same vault.
|
|
381
|
+
run.releaseKeyedJournal?.();
|
|
382
|
+
}
|
|
353
383
|
}
|
|
354
384
|
}
|
|
355
385
|
async function buildPullContext(options) {
|
|
@@ -438,8 +468,9 @@ async function buildPullContext(options) {
|
|
|
438
468
|
? targetedJournalSession(journalSlug)
|
|
439
469
|
: undefined;
|
|
440
470
|
const keyedJournal = keyedJournalLease?.store;
|
|
471
|
+
let remoteFiles;
|
|
441
472
|
try {
|
|
442
|
-
|
|
473
|
+
remoteFiles = await listRemoteFilesForScope(ctx, syncMode, listedPrefixSet, requestedPullPrefixes.length > 0);
|
|
443
474
|
const fileTombstones = options.fileTombstones ??
|
|
444
475
|
await fetchFileTombstones(vaultConfig, options.personalMode === true
|
|
445
476
|
? { personal: true }
|
|
@@ -487,43 +518,27 @@ async function buildPullContext(options) {
|
|
|
487
518
|
catch (error) {
|
|
488
519
|
// `buildPullContext` can fail during LIST/freshness. Do not leave the
|
|
489
520
|
// leased projection marked in use when no PullRunContext is returned.
|
|
521
|
+
if (remoteFiles && isStreamingRemoteFileSource(remoteFiles))
|
|
522
|
+
remoteFiles.dispose();
|
|
490
523
|
keyedJournalLease?.release();
|
|
491
524
|
throw error;
|
|
492
525
|
}
|
|
493
526
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
*/
|
|
527
|
+
function isStreamingRemoteFileSource(source) {
|
|
528
|
+
return !Array.isArray(source);
|
|
529
|
+
}
|
|
530
|
+
function allModeRemoteFileStream(ctx) {
|
|
531
|
+
return spoolRemoteFiles(streamRemoteFiles(ctx));
|
|
532
|
+
}
|
|
518
533
|
async function listRemoteFilesForScope(ctx, syncMode, listedPrefixSet, isTargetedPull) {
|
|
519
534
|
const entries = toScopePrefixEntries(listedPrefixSet);
|
|
520
535
|
if (entries.some((e) => e.prefix === ""))
|
|
521
|
-
return
|
|
536
|
+
return allModeRemoteFileStream(ctx);
|
|
522
537
|
if (entries.length === 0) {
|
|
523
538
|
// `all` mode always carries `[""]`, so an empty set only occurs for a
|
|
524
539
|
// scoped mode (empty grants, or a targeted intersection that excluded
|
|
525
540
|
// everything). Defensive `all` branch: never silently narrow a full pull.
|
|
526
|
-
return syncMode === "all" ?
|
|
541
|
+
return syncMode === "all" ? allModeRemoteFileStream(ctx) : [];
|
|
527
542
|
}
|
|
528
543
|
return listRemoteForScope({
|
|
529
544
|
ctx,
|
|
@@ -544,8 +559,14 @@ async function listRemoteFilesForScope(ctx, syncMode, listedPrefixSet, isTargete
|
|
|
544
559
|
},
|
|
545
560
|
});
|
|
546
561
|
}
|
|
547
|
-
function planPull(run) {
|
|
548
|
-
|
|
562
|
+
async function planPull(run) {
|
|
563
|
+
try {
|
|
564
|
+
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);
|
|
565
|
+
}
|
|
566
|
+
finally {
|
|
567
|
+
if (isStreamingRemoteFileSource(run.remoteFiles))
|
|
568
|
+
run.remoteFiles.dispose();
|
|
569
|
+
}
|
|
549
570
|
}
|
|
550
571
|
/** Build only the rows a targeted remote LIST can observe. */
|
|
551
572
|
function journalForTargetedPull(store, prefixes) {
|
|
@@ -658,9 +679,55 @@ function createPullCounters() {
|
|
|
658
679
|
conflictPaths: [],
|
|
659
680
|
changedPaths: [],
|
|
660
681
|
appliedUpserts: [],
|
|
682
|
+
newFiles: [],
|
|
661
683
|
hasRetryableOutcome: false,
|
|
662
684
|
};
|
|
663
685
|
}
|
|
686
|
+
/** Fold a previous attempt's completed work into a restarted run's counters. */
|
|
687
|
+
function seedCarriedPullProgress(counters, carried) {
|
|
688
|
+
if (!carried)
|
|
689
|
+
return;
|
|
690
|
+
counters.filesDownloaded += carried.filesDownloaded;
|
|
691
|
+
counters.bytesDownloaded += carried.bytesDownloaded;
|
|
692
|
+
counters.conflicts += carried.conflicts;
|
|
693
|
+
counters.filesTombstoned += carried.filesTombstoned;
|
|
694
|
+
counters.conflictPaths.push(...carried.conflictPaths);
|
|
695
|
+
counters.changedPaths.push(...carried.changedPaths);
|
|
696
|
+
counters.appliedUpserts.push(...carried.appliedUpserts);
|
|
697
|
+
if (carried.hasRetryableOutcome)
|
|
698
|
+
counters.hasRetryableOutcome = true;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Snapshot what survives the hand-off. The numeric and path axes already
|
|
702
|
+
* include everything carried into this attempt; `newFiles` is accumulated
|
|
703
|
+
* separately so it stays a record of pre-restart downloads only, and cannot
|
|
704
|
+
* double up with the current plan's own new files.
|
|
705
|
+
*/
|
|
706
|
+
function carryPullProgress(previous, counters) {
|
|
707
|
+
return {
|
|
708
|
+
filesDownloaded: counters.filesDownloaded,
|
|
709
|
+
bytesDownloaded: counters.bytesDownloaded,
|
|
710
|
+
conflicts: counters.conflicts,
|
|
711
|
+
conflictPaths: [...counters.conflictPaths],
|
|
712
|
+
filesTombstoned: counters.filesTombstoned,
|
|
713
|
+
changedPaths: [...counters.changedPaths],
|
|
714
|
+
appliedUpserts: [...counters.appliedUpserts],
|
|
715
|
+
newFiles: [...(previous?.newFiles ?? []), ...counters.newFiles],
|
|
716
|
+
hasRetryableOutcome: counters.hasRetryableOutcome,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Every new file this pull is responsible for reporting: the ones a previous
|
|
721
|
+
* attempt downloaded before handing off, then this plan's own. A carried path
|
|
722
|
+
* that the fresh plan also lists is reported once.
|
|
723
|
+
*/
|
|
724
|
+
function reportableNewFiles(run, plan) {
|
|
725
|
+
const carried = run.carriedProgress?.newFiles;
|
|
726
|
+
if (carried === undefined || carried.length === 0)
|
|
727
|
+
return plan.newFiles;
|
|
728
|
+
const planned = new Set(plan.newFiles.map((newFile) => newFile.path));
|
|
729
|
+
return [...carried.filter((newFile) => !planned.has(newFile.path)), ...plan.newFiles];
|
|
730
|
+
}
|
|
664
731
|
function planScopeShrink(run) {
|
|
665
732
|
const lastRecord = lastPullRecord(run.journal, run.ctx.uid);
|
|
666
733
|
const lastPrefixSet = lastRecord && lastRecord.prefixSet.length > 0
|
|
@@ -1121,6 +1188,7 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
|
|
|
1121
1188
|
if (resolution === "abort") {
|
|
1122
1189
|
run.emit({ type: "new-files", files: [] });
|
|
1123
1190
|
persistPullJournal(run);
|
|
1191
|
+
const abortedNewFiles = reportableNewFiles(run, plan);
|
|
1124
1192
|
return {
|
|
1125
1193
|
filesDownloaded: counters.filesDownloaded,
|
|
1126
1194
|
bytesDownloaded: counters.bytesDownloaded,
|
|
@@ -1128,8 +1196,8 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
|
|
|
1128
1196
|
conflicts: counters.conflicts,
|
|
1129
1197
|
conflictPaths: counters.conflictPaths,
|
|
1130
1198
|
aborted: true,
|
|
1131
|
-
newFiles:
|
|
1132
|
-
newFilesCount:
|
|
1199
|
+
newFiles: abortedNewFiles,
|
|
1200
|
+
newFilesCount: abortedNewFiles.length,
|
|
1133
1201
|
filesExcludedByPolicy: plan.filesExcludedByPolicy,
|
|
1134
1202
|
filesTombstoned: 0,
|
|
1135
1203
|
filesOutOfScope: counters.filesOutOfScope,
|
|
@@ -1255,10 +1323,21 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
|
|
|
1255
1323
|
discardProbe();
|
|
1256
1324
|
}
|
|
1257
1325
|
}
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1326
|
+
/**
|
|
1327
|
+
* The old plan may have tombstone evidence that predates a realtime hand-off.
|
|
1328
|
+
* Rebuilding from disk after a hand-off makes the continuation observe the
|
|
1329
|
+
* realtime journal and a fresh LIST before it can perform another mutation.
|
|
1330
|
+
*/
|
|
1331
|
+
class CooperativePullHandoff extends Error {
|
|
1332
|
+
/** Completed work the restarted pull must keep reporting. */
|
|
1333
|
+
completed;
|
|
1334
|
+
constructor(completed) {
|
|
1335
|
+
super("full pull handed off to realtime work");
|
|
1336
|
+
this.name = "CooperativePullHandoff";
|
|
1337
|
+
this.completed = completed;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
async function executeDownloadQueue(run, downloadItems, transferConcurrency, counters, deferJournalCheckpoint) {
|
|
1262
1341
|
const queue = [...downloadItems];
|
|
1263
1342
|
const inFlight = new Set();
|
|
1264
1343
|
const workerErrors = [];
|
|
@@ -1273,7 +1352,7 @@ async function executeDownloadExecutor(run, downloadItems, transferConcurrency,
|
|
|
1273
1352
|
inFlight.size < transferConcurrency &&
|
|
1274
1353
|
queue.length > 0) {
|
|
1275
1354
|
const downloadItem = queue.shift();
|
|
1276
|
-
const download = () => downloadOne(run, downloadItem, counters);
|
|
1355
|
+
const download = () => downloadOne(run, downloadItem, counters, deferJournalCheckpoint);
|
|
1277
1356
|
const p = (run.options.transferSemaphore
|
|
1278
1357
|
? run.options.transferSemaphore.withPermit(download)
|
|
1279
1358
|
: download())
|
|
@@ -1297,16 +1376,44 @@ async function executeDownloadExecutor(run, downloadItems, transferConcurrency,
|
|
|
1297
1376
|
break;
|
|
1298
1377
|
}
|
|
1299
1378
|
}
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1379
|
+
return workerErrors;
|
|
1380
|
+
}
|
|
1381
|
+
function throwDownloadWorkerErrors(run, workerErrors) {
|
|
1382
|
+
persistPullJournal(run);
|
|
1383
|
+
const first = workerErrors[0];
|
|
1384
|
+
if (workerErrors.length > 1) {
|
|
1385
|
+
first.message = `${first.message} (and ${workerErrors.length - 1} more download-worker errors)`;
|
|
1386
|
+
}
|
|
1387
|
+
throw first;
|
|
1388
|
+
}
|
|
1389
|
+
async function executeDownloadExecutor(run, downloadItems, transferConcurrency, counters) {
|
|
1390
|
+
if (downloadItems.length === 0)
|
|
1391
|
+
return;
|
|
1392
|
+
await primeObjectTransport(run.ctx, "get", downloadItems.map((d) => d.remoteFile.key));
|
|
1393
|
+
// Ordinary and targeted pulls retain the existing 10k crash-recovery cadence.
|
|
1394
|
+
// Only a watch chore that supplied the cooperative seam pays for drained
|
|
1395
|
+
// units and can return its root lock to queued realtime work.
|
|
1396
|
+
if (!run.options.cooperativeCheckpoint) {
|
|
1397
|
+
const workerErrors = await executeDownloadQueue(run, downloadItems, transferConcurrency, counters, false);
|
|
1398
|
+
if (workerErrors.length > 0)
|
|
1399
|
+
throwDownloadWorkerErrors(run, workerErrors);
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
for (let start = 0; start < downloadItems.length; start += FULL_PULL_COOPERATIVE_CHECKPOINT_BATCH_SIZE) {
|
|
1403
|
+
const chunk = downloadItems.slice(start, start + FULL_PULL_COOPERATIVE_CHECKPOINT_BATCH_SIZE);
|
|
1404
|
+
const workerErrors = await executeDownloadQueue(run, chunk, transferConcurrency, counters, true);
|
|
1405
|
+
// `executeDownloadQueue` does not return until every member of this chunk
|
|
1406
|
+
// has settled. Persist before the callback so the suspended chore owns no
|
|
1407
|
+
// mutable pass state while realtime uses the root lock.
|
|
1408
|
+
flushDownloadedJournalTail(run);
|
|
1409
|
+
if (workerErrors.length > 0)
|
|
1410
|
+
throwDownloadWorkerErrors(run, workerErrors);
|
|
1411
|
+
if (await run.options.cooperativeCheckpoint() === true) {
|
|
1412
|
+
throw new CooperativePullHandoff(carryPullProgress(run.carriedProgress, counters));
|
|
1305
1413
|
}
|
|
1306
|
-
throw first;
|
|
1307
1414
|
}
|
|
1308
1415
|
}
|
|
1309
|
-
async function downloadOne(run, downloadItem, counters) {
|
|
1416
|
+
async function downloadOne(run, downloadItem, counters, deferJournalCheckpoint = false) {
|
|
1310
1417
|
const { remoteFile, localPath } = downloadItem;
|
|
1311
1418
|
await refreshRunContextIfExpiring(run);
|
|
1312
1419
|
if (!isDownloadWritePathStillContained(run.companyRoot, remoteFile.key, localPath)) {
|
|
@@ -1375,6 +1482,9 @@ async function downloadOne(run, downloadItem, counters) {
|
|
|
1375
1482
|
counters.filesDownloaded++;
|
|
1376
1483
|
counters.bytesDownloaded += size;
|
|
1377
1484
|
counters.changedPaths.push(remoteFile.key);
|
|
1485
|
+
if (downloadItem.isNew) {
|
|
1486
|
+
counters.newFiles.push({ path: remoteFile.key, bytes: remoteFile.size });
|
|
1487
|
+
}
|
|
1378
1488
|
// The event-push emitter hashes regular file bodies. Symlink journal hashes
|
|
1379
1489
|
// describe link targets instead, and the startup baseline excludes them for
|
|
1380
1490
|
// the same reason, so only hand regular-file downloads to that ledger.
|
|
@@ -1483,7 +1593,8 @@ async function downloadOne(run, downloadItem, counters) {
|
|
|
1483
1593
|
return;
|
|
1484
1594
|
}
|
|
1485
1595
|
run.downloadsSinceJournalCheckpoint++;
|
|
1486
|
-
if (
|
|
1596
|
+
if (!deferJournalCheckpoint &&
|
|
1597
|
+
run.downloadsSinceJournalCheckpoint >= DOWNLOAD_JOURNAL_CHECKPOINT_BATCH_SIZE) {
|
|
1487
1598
|
flushDownloadedJournalTail(run);
|
|
1488
1599
|
}
|
|
1489
1600
|
}
|
|
@@ -1516,15 +1627,19 @@ function persistPullJournal(run) {
|
|
|
1516
1627
|
}
|
|
1517
1628
|
async function emitAndReportNewFiles(run, plan) {
|
|
1518
1629
|
const enrichedNewFiles = [];
|
|
1630
|
+
// New files a cooperative hand-off already downloaded are reported here too:
|
|
1631
|
+
// the restarted plan cannot see them as new, so this is their only
|
|
1632
|
+
// notification.
|
|
1633
|
+
const newFiles = reportableNewFiles(run, plan);
|
|
1519
1634
|
// Batch-mint the GET presigns once (chunked, breaker-aware) so the per-file
|
|
1520
1635
|
// created-by HEADs below reuse the cache instead of each minting its own
|
|
1521
1636
|
// presign. Without this, a big catch-up pull (hundreds of new files) bursts
|
|
1522
1637
|
// the presign endpoint, trips the circuit breaker, and every enrichment HEAD
|
|
1523
1638
|
// then fails. Mirrors the tombstone HEAD-verify pre-prime.
|
|
1524
|
-
await primeObjectTransport(run.ctx, "get",
|
|
1639
|
+
await primeObjectTransport(run.ctx, "get", newFiles.map((nf) => nf.path));
|
|
1525
1640
|
const HEAD_CONCURRENCY = 5;
|
|
1526
|
-
for (let i = 0; i <
|
|
1527
|
-
const batch =
|
|
1641
|
+
for (let i = 0; i < newFiles.length; i += HEAD_CONCURRENCY) {
|
|
1642
|
+
const batch = newFiles.slice(i, i + HEAD_CONCURRENCY);
|
|
1528
1643
|
const results = await Promise.all(batch.map(async (nf) => {
|
|
1529
1644
|
let addedBy = null;
|
|
1530
1645
|
try {
|
|
@@ -1705,6 +1820,7 @@ function finalizePullRun(run, plan, scopeRun, counters) {
|
|
|
1705
1820
|
// best-effort: a post-sync refresh failure never fails the sync
|
|
1706
1821
|
}
|
|
1707
1822
|
}
|
|
1823
|
+
const newFiles = reportableNewFiles(run, plan);
|
|
1708
1824
|
const result = {
|
|
1709
1825
|
filesDownloaded: counters.filesDownloaded,
|
|
1710
1826
|
bytesDownloaded: counters.bytesDownloaded,
|
|
@@ -1712,8 +1828,8 @@ function finalizePullRun(run, plan, scopeRun, counters) {
|
|
|
1712
1828
|
conflicts: counters.conflicts,
|
|
1713
1829
|
conflictPaths: counters.conflictPaths,
|
|
1714
1830
|
aborted: false,
|
|
1715
|
-
newFiles
|
|
1716
|
-
newFilesCount:
|
|
1831
|
+
newFiles,
|
|
1832
|
+
newFilesCount: newFiles.length,
|
|
1717
1833
|
filesExcludedByPolicy: plan.filesExcludedByPolicy,
|
|
1718
1834
|
filesTombstoned: counters.filesTombstoned,
|
|
1719
1835
|
filesOutOfScope: counters.filesOutOfScope,
|
|
@@ -1942,7 +2058,8 @@ class LocalSnapshotChangedError extends Error {
|
|
|
1942
2058
|
* the real outcome of the upcoming Stage-2 execution rather than an
|
|
1943
2059
|
* upper-bound guess.
|
|
1944
2060
|
*
|
|
1945
|
-
*
|
|
2061
|
+
* It performs no writes, journal mutations, or event emission. An all-mode
|
|
2062
|
+
* source performs its paged classification LIST while it is consumed; the
|
|
1946
2063
|
* caller (`sync()`) is responsible for emitting the resulting plan event
|
|
1947
2064
|
* before iterating `items`.
|
|
1948
2065
|
*/
|
|
@@ -2010,6 +2127,36 @@ excludePrefixes = [],
|
|
|
2010
2127
|
// callers preserve the pre-policy, watcher-intent-only behavior.
|
|
2011
2128
|
propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows(journal).size) {
|
|
2012
2129
|
const items = [];
|
|
2130
|
+
const streaming = isStreamingRemoteFileSource(remoteFiles);
|
|
2131
|
+
const listedFiles = isStreamingRemoteFileSource(remoteFiles) ? remoteFiles.files : remoteFiles;
|
|
2132
|
+
let preplannedFilesSkipped = 0;
|
|
2133
|
+
let preplannedFilesOutOfScope = 0;
|
|
2134
|
+
let preplannedFilesExcludedByPolicy = 0;
|
|
2135
|
+
if (streaming) {
|
|
2136
|
+
const retain = items.push.bind(items);
|
|
2137
|
+
items.push = (...next) => {
|
|
2138
|
+
for (const item of next) {
|
|
2139
|
+
// These actions still need the executor for an event or a state change.
|
|
2140
|
+
if (item.action === "download" ||
|
|
2141
|
+
item.action === "conflict" ||
|
|
2142
|
+
item.action === "tombstone-delete" ||
|
|
2143
|
+
item.action === "skip-junk-key-spelling" ||
|
|
2144
|
+
item.action === "skip-unreadable-link") {
|
|
2145
|
+
retain(item);
|
|
2146
|
+
}
|
|
2147
|
+
else if (item.action === "skip-out-of-scope") {
|
|
2148
|
+
preplannedFilesOutOfScope++;
|
|
2149
|
+
}
|
|
2150
|
+
else if (item.action === "skip-excluded-policy") {
|
|
2151
|
+
preplannedFilesExcludedByPolicy++;
|
|
2152
|
+
}
|
|
2153
|
+
else {
|
|
2154
|
+
preplannedFilesSkipped++;
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
return items.length;
|
|
2158
|
+
};
|
|
2159
|
+
}
|
|
2013
2160
|
// Remote keys the server will never presign — collected so the caller can
|
|
2014
2161
|
// warn + emit telemetry without allowing one poison object to fail a pull.
|
|
2015
2162
|
const invalidRemoteKeys = [];
|
|
@@ -2025,8 +2172,35 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
|
|
|
2025
2172
|
// never gated by this map (see the guard below), so pre-existing families
|
|
2026
2173
|
// keep syncing untouched until the vault doctor (US-003) collapses them.
|
|
2027
2174
|
const journaledKeyByCanonical = new Map();
|
|
2175
|
+
// The delete sweep starts with journaled keys and clears them as the streamed
|
|
2176
|
+
// LIST proves each one present. This retains existing journal-key references
|
|
2177
|
+
// instead of a second, whole-vault population of long remote key strings.
|
|
2178
|
+
const unseenJournalKeys = new Set();
|
|
2179
|
+
// Most journal keys have already been normalized to POSIX and can be cleared
|
|
2180
|
+
// directly. Keep the legacy Windows spelling defense-in-depth without
|
|
2181
|
+
// imposing a second map entry on every current journal row.
|
|
2182
|
+
const legacyJournalKeysByPosix = new Map();
|
|
2183
|
+
// The junk-spelling delete guard needs all journal twins, while
|
|
2184
|
+
// `journaledKeyByCanonical` above deliberately retains only the preferred
|
|
2185
|
+
// representative for classification.
|
|
2186
|
+
const journaledSkillKeysByCanonical = new Map();
|
|
2028
2187
|
for (const journaledKey of journalRows(journal).keys()) {
|
|
2029
2188
|
onHeartbeat?.();
|
|
2189
|
+
const posixJournaledKey = toPosixKey(journaledKey);
|
|
2190
|
+
// A scoped LIST cannot prove anything about keys outside its prefixes.
|
|
2191
|
+
// Do not copy the entire vault's journal into its presence bookkeeping.
|
|
2192
|
+
const inListedScope = isCoveredByAny(posixJournaledKey, prefixSet);
|
|
2193
|
+
if (inListedScope)
|
|
2194
|
+
unseenJournalKeys.add(journaledKey);
|
|
2195
|
+
if (inListedScope && posixJournaledKey !== journaledKey) {
|
|
2196
|
+
const legacyKeys = legacyJournalKeysByPosix.get(posixJournaledKey);
|
|
2197
|
+
if (legacyKeys === undefined) {
|
|
2198
|
+
legacyJournalKeysByPosix.set(posixJournaledKey, [journaledKey]);
|
|
2199
|
+
}
|
|
2200
|
+
else {
|
|
2201
|
+
legacyKeys.push(journaledKey);
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2030
2204
|
if (!journaledKey.startsWith(SKILLS_KEY_PREFIX))
|
|
2031
2205
|
continue;
|
|
2032
2206
|
const canonical = canonicalVaultKeySpelling(journaledKey);
|
|
@@ -2034,6 +2208,15 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
|
|
|
2034
2208
|
if (existing === undefined || journaledKey === canonical) {
|
|
2035
2209
|
journaledKeyByCanonical.set(canonical, journaledKey);
|
|
2036
2210
|
}
|
|
2211
|
+
if (!inListedScope)
|
|
2212
|
+
continue;
|
|
2213
|
+
const skillKeys = journaledSkillKeysByCanonical.get(canonical);
|
|
2214
|
+
if (skillKeys === undefined) {
|
|
2215
|
+
journaledSkillKeysByCanonical.set(canonical, [journaledKey]);
|
|
2216
|
+
}
|
|
2217
|
+
else {
|
|
2218
|
+
skillKeys.push(journaledKey);
|
|
2219
|
+
}
|
|
2037
2220
|
}
|
|
2038
2221
|
// Remote keys that ALSO appear as an ancestor of another remote key — i.e.
|
|
2039
2222
|
// keys that carry child objects under `${key}/…` in this same LIST. The
|
|
@@ -2043,22 +2226,40 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
|
|
|
2043
2226
|
// objects beneath it. A REAL directory, by contrast, only ever syncs as
|
|
2044
2227
|
// those child objects — never as a single object AT its own key. So a single
|
|
2045
2228
|
// remote object sitting at a key that also has children is unambiguously a
|
|
2046
|
-
// directory-overlay marker, not a regular file.
|
|
2047
|
-
//
|
|
2048
|
-
//
|
|
2049
|
-
//
|
|
2050
|
-
const remoteKeysWithChildren =
|
|
2051
|
-
|
|
2229
|
+
// directory-overlay marker, not a regular file. Evidence is complete before
|
|
2230
|
+
// classification for both the spooled all-mode LIST and scoped arrays.
|
|
2231
|
+
// Build scoped evidence incrementally: flatMap would transiently retain an
|
|
2232
|
+
// ancestor string for every row/depth before Set deduplication.
|
|
2233
|
+
const remoteKeysWithChildren = isStreamingRemoteFileSource(remoteFiles)
|
|
2234
|
+
? remoteFiles.remoteKeysWithChildren
|
|
2235
|
+
: collectRemoteAncestors(remoteFiles);
|
|
2236
|
+
// Both sources replay synchronously. for-await on a scoped array introduces
|
|
2237
|
+
// promise/microtask work per object even though no I/O is being awaited.
|
|
2238
|
+
for (const remoteFile of listedFiles) {
|
|
2052
2239
|
onHeartbeat?.();
|
|
2053
|
-
const
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2240
|
+
const posixRemoteKey = toPosixKey(remoteFile.key);
|
|
2241
|
+
// S3 object keys are POSIX. Match the old delete-sweep membership check:
|
|
2242
|
+
// a legacy backslash journal entry counts as present only when its POSIX
|
|
2243
|
+
// form appears in the LIST, while a malformed backslash remote key does
|
|
2244
|
+
// not accidentally change that behavior.
|
|
2245
|
+
if (remoteFile.key === posixRemoteKey) {
|
|
2246
|
+
unseenJournalKeys.delete(remoteFile.key);
|
|
2247
|
+
for (const legacyKey of legacyJournalKeysByPosix.get(remoteFile.key) ?? []) {
|
|
2248
|
+
unseenJournalKeys.delete(legacyKey);
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
if (remoteFile.key.startsWith(SKILLS_KEY_PREFIX)) {
|
|
2252
|
+
// A remote junk spelling means its canonical journal twin is still
|
|
2253
|
+
// remotely present. Keep that twin out of the delete sweep unless an
|
|
2254
|
+
// exact FILE_TOMBSTONE is authoritative, precisely matching the old
|
|
2255
|
+
// remoteCanonicalSkillKeys protection without retaining remote keys.
|
|
2256
|
+
const canonical = canonicalVaultKeySpelling(remoteFile.key);
|
|
2257
|
+
for (const journaledKey of journaledSkillKeysByCanonical.get(canonical) ?? []) {
|
|
2258
|
+
if (fileTombstones.get(toPosixKey(journaledKey)) === undefined) {
|
|
2259
|
+
unseenJournalKeys.delete(journaledKey);
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2058
2262
|
}
|
|
2059
|
-
}
|
|
2060
|
-
for (const remoteFile of remoteFiles) {
|
|
2061
|
-
onHeartbeat?.();
|
|
2062
2263
|
const invalidKey = classifyVaultKey(remoteFile.key, personalMode ? "personal" : "company");
|
|
2063
2264
|
if (invalidKey !== null) {
|
|
2064
2265
|
invalidRemoteKeys.push({
|
|
@@ -2711,6 +2912,9 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
|
|
|
2711
2912
|
filesToSkip++;
|
|
2712
2913
|
}
|
|
2713
2914
|
}
|
|
2915
|
+
filesToSkip += preplannedFilesSkipped;
|
|
2916
|
+
filesExcludedByPolicy += preplannedFilesExcludedByPolicy;
|
|
2917
|
+
filesOutOfScope += preplannedFilesOutOfScope;
|
|
2714
2918
|
// Bug #9 — cross-machine delete propagation. The peer's push leg
|
|
2715
2919
|
// already removed the file from S3 (verified in the deep-test
|
|
2716
2920
|
// addendum: `aws s3 ls` showed the keys absent post-push). Pre-fix
|
|
@@ -2719,38 +2923,21 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
|
|
|
2719
2923
|
// that-was-there-before" — so the file stayed forever on every
|
|
2720
2924
|
// receiver and `filesTombstoned: 0` showed up on every pull.
|
|
2721
2925
|
//
|
|
2722
|
-
// Closing the loop: walk the
|
|
2723
|
-
//
|
|
2926
|
+
// Closing the loop: walk journal keys the streamed LIST did NOT clear, AND
|
|
2927
|
+
// whose path passes the current
|
|
2724
2928
|
// ignore filter (paths newly excluded by .hqignore must not trigger
|
|
2725
2929
|
// mass-delete), AND — in personalMode — survives the same companies/*
|
|
2726
2930
|
// gating the download branch applies. The executor will apply each
|
|
2727
2931
|
// as a local delete + journal removal. Symmetric to the push side's
|
|
2728
2932
|
// `propagateDeletes` plan in share.ts.
|
|
2729
|
-
const remoteKeySet = new Set();
|
|
2730
|
-
// Skill-farm twin protection (companion to the junk-spelling pull guard):
|
|
2731
|
-
// when the LIST holds only a junk spelling of a journaled canonical key,
|
|
2732
|
-
// the logical object is still remotely present — a raw has() would class
|
|
2733
|
-
// the canonical journal entry as remote-deleted, unlink the good local
|
|
2734
|
-
// wrapper, and drop the journal entry (after which the next pull would
|
|
2735
|
-
// materialize the junk spelling the guard just refused). Track canonical
|
|
2736
|
-
// forms of listed skill keys and consult them below.
|
|
2737
|
-
const remoteCanonicalSkillKeys = new Set();
|
|
2738
|
-
for (const rf of remoteFiles) {
|
|
2739
|
-
remoteKeySet.add(rf.key);
|
|
2740
|
-
if (rf.key.startsWith(SKILLS_KEY_PREFIX)) {
|
|
2741
|
-
remoteCanonicalSkillKeys.add(canonicalVaultKeySpelling(rf.key));
|
|
2742
|
-
}
|
|
2743
|
-
}
|
|
2744
2933
|
const tombstones = [];
|
|
2745
|
-
for (const key of
|
|
2934
|
+
for (const key of unseenJournalKeys) {
|
|
2746
2935
|
// Compare membership in POSIX space. A pre-5.47.2 Windows journal key can
|
|
2747
2936
|
// carry backslash separators; the remote LIST is always forward-slash, so a
|
|
2748
2937
|
// raw `has(key)` would miss the match and class a live local file as
|
|
2749
2938
|
// remote-deleted. normalizeJournalKeys() defuses this on load, but the
|
|
2750
2939
|
// POSIX compare is defense-in-depth (ridge data-loss, feedback_b8d09d0f).
|
|
2751
2940
|
const posixKey = toPosixKey(key);
|
|
2752
|
-
if (remoteKeySet.has(posixKey))
|
|
2753
|
-
continue;
|
|
2754
2941
|
// Listing-scope guard (targeted sync / scoped listing). The remote LIST
|
|
2755
2942
|
// now covers ONLY `prefixSet` (scoped modes list per-prefix; a targeted
|
|
2756
2943
|
// pull narrows further), so "absent from the LIST" is evidence of a remote
|
|
@@ -2762,16 +2949,6 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
|
|
|
2762
2949
|
// `all` mode passes `[""]`, which covers every key — no behavior change.
|
|
2763
2950
|
if (!isCoveredByAny(posixKey, prefixSet))
|
|
2764
2951
|
continue;
|
|
2765
|
-
if (posixKey.startsWith(SKILLS_KEY_PREFIX) &&
|
|
2766
|
-
remoteCanonicalSkillKeys.has(canonicalVaultKeySpelling(posixKey)) &&
|
|
2767
|
-
// An EXACT FILE_TOMBSTONE for the journaled key is an authoritative
|
|
2768
|
-
// delete and wins over the twin protection — a lingering junk
|
|
2769
|
-
// spelling in the LIST must not keep a deliberately-deleted wrapper
|
|
2770
|
-
// alive forever. (The key is absent from the LIST here, so there is
|
|
2771
|
-
// no newer re-create to defer to.)
|
|
2772
|
-
fileTombstones.get(posixKey) === undefined) {
|
|
2773
|
-
continue;
|
|
2774
|
-
}
|
|
2775
2952
|
// Fast-path ignore, BEFORE containment canonicalization. The tombstone
|
|
2776
2953
|
// sweep runs for every journaled key, and a stale journal can carry
|
|
2777
2954
|
// hundreds of thousands of entries under an ignored runtime tree (e.g.
|
|
@@ -2880,6 +3057,8 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
|
|
|
2880
3057
|
}
|
|
2881
3058
|
return {
|
|
2882
3059
|
items,
|
|
3060
|
+
preplannedFilesSkipped,
|
|
3061
|
+
preplannedFilesOutOfScope,
|
|
2883
3062
|
filesToDownload,
|
|
2884
3063
|
bytesToDownload,
|
|
2885
3064
|
filesToSkip,
|