@indigoai-us/hq-cloud 6.16.65 → 6.16.67

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.
Files changed (126) hide show
  1. package/dist/bin/sync-runner-company.d.ts +14 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  3. package/dist/bin/sync-runner-company.js +42 -8
  4. package/dist/bin/sync-runner-company.js.map +1 -1
  5. package/dist/bin/sync-runner-company.test.js +62 -7
  6. package/dist/bin/sync-runner-company.test.js.map +1 -1
  7. package/dist/bin/sync-runner-planning.d.ts +12 -3
  8. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  9. package/dist/bin/sync-runner-planning.js +65 -3
  10. package/dist/bin/sync-runner-planning.js.map +1 -1
  11. package/dist/bin/sync-runner-planning.test.js +172 -1
  12. package/dist/bin/sync-runner-planning.test.js.map +1 -1
  13. package/dist/bin/sync-runner-watch-loop.d.ts +51 -2
  14. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  15. package/dist/bin/sync-runner-watch-loop.js +409 -165
  16. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  17. package/dist/bin/sync-runner-watch-routes.d.ts +19 -2
  18. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  19. package/dist/bin/sync-runner-watch-routes.js +40 -5
  20. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  21. package/dist/bin/sync-runner-watch-routes.test.js +47 -0
  22. package/dist/bin/sync-runner-watch-routes.test.js.map +1 -1
  23. package/dist/bin/sync-runner.d.ts +59 -5
  24. package/dist/bin/sync-runner.d.ts.map +1 -1
  25. package/dist/bin/sync-runner.js +48 -4
  26. package/dist/bin/sync-runner.js.map +1 -1
  27. package/dist/bin/sync-runner.test.js +1553 -192
  28. package/dist/bin/sync-runner.test.js.map +1 -1
  29. package/dist/cli/onepass-pull-planner-regressions.test.d.ts +2 -0
  30. package/dist/cli/onepass-pull-planner-regressions.test.d.ts.map +1 -0
  31. package/dist/cli/onepass-pull-planner-regressions.test.js +300 -0
  32. package/dist/cli/onepass-pull-planner-regressions.test.js.map +1 -0
  33. package/dist/cli/onepass-pull-planner.test.d.ts +2 -0
  34. package/dist/cli/onepass-pull-planner.test.d.ts.map +1 -0
  35. package/dist/cli/onepass-pull-planner.test.js +266 -0
  36. package/dist/cli/onepass-pull-planner.test.js.map +1 -0
  37. package/dist/cli/push-scan-exclusions.test.js +4 -4
  38. package/dist/cli/push-scan-exclusions.test.js.map +1 -1
  39. package/dist/cli/share.d.ts +31 -1
  40. package/dist/cli/share.d.ts.map +1 -1
  41. package/dist/cli/share.js +143 -8
  42. package/dist/cli/share.js.map +1 -1
  43. package/dist/cli/share.test.js +350 -5
  44. package/dist/cli/share.test.js.map +1 -1
  45. package/dist/cli/streaming-pull-planner.bench.test.d.ts +15 -0
  46. package/dist/cli/streaming-pull-planner.bench.test.d.ts.map +1 -0
  47. package/dist/cli/streaming-pull-planner.bench.test.js +273 -0
  48. package/dist/cli/streaming-pull-planner.bench.test.js.map +1 -0
  49. package/dist/cli/sync-scope.test.js +8 -0
  50. package/dist/cli/sync-scope.test.js.map +1 -1
  51. package/dist/cli/sync.d.ts +12 -0
  52. package/dist/cli/sync.d.ts.map +1 -1
  53. package/dist/cli/sync.js +297 -125
  54. package/dist/cli/sync.js.map +1 -1
  55. package/dist/cli/sync.test.js +255 -2
  56. package/dist/cli/sync.test.js.map +1 -1
  57. package/dist/journal-row-store.d.ts +40 -5
  58. package/dist/journal-row-store.d.ts.map +1 -1
  59. package/dist/journal-row-store.js +240 -21
  60. package/dist/journal-row-store.js.map +1 -1
  61. package/dist/journal-row-store.test.js +54 -0
  62. package/dist/journal-row-store.test.js.map +1 -1
  63. package/dist/journal-snapshot-v4.d.ts.map +1 -1
  64. package/dist/journal-snapshot-v4.js +11 -6
  65. package/dist/journal-snapshot-v4.js.map +1 -1
  66. package/dist/journal-store.test.js +324 -7
  67. package/dist/journal-store.test.js.map +1 -1
  68. package/dist/journal.d.ts +51 -16
  69. package/dist/journal.d.ts.map +1 -1
  70. package/dist/journal.js +303 -79
  71. package/dist/journal.js.map +1 -1
  72. package/dist/lib/exit-codes.d.ts +15 -0
  73. package/dist/lib/exit-codes.d.ts.map +1 -1
  74. package/dist/lib/exit-codes.js +15 -0
  75. package/dist/lib/exit-codes.js.map +1 -1
  76. package/dist/personal-vault.d.ts.map +1 -1
  77. package/dist/personal-vault.js +10 -3
  78. package/dist/personal-vault.js.map +1 -1
  79. package/dist/personal-vault.test.js +35 -0
  80. package/dist/personal-vault.test.js.map +1 -1
  81. package/dist/remote-pull.d.ts.map +1 -1
  82. package/dist/remote-pull.js +9 -2
  83. package/dist/remote-pull.js.map +1 -1
  84. package/dist/remote-pull.test.js +59 -2
  85. package/dist/remote-pull.test.js.map +1 -1
  86. package/dist/s3.d.ts +1 -0
  87. package/dist/s3.d.ts.map +1 -1
  88. package/dist/s3.js +20 -2
  89. package/dist/s3.js.map +1 -1
  90. package/dist/s3.list-cost.test.d.ts +2 -0
  91. package/dist/s3.list-cost.test.d.ts.map +1 -0
  92. package/dist/s3.list-cost.test.js +51 -0
  93. package/dist/s3.list-cost.test.js.map +1 -0
  94. package/dist/sync/area-ledger.d.ts +27 -3
  95. package/dist/sync/area-ledger.d.ts.map +1 -1
  96. package/dist/sync/area-ledger.js +149 -39
  97. package/dist/sync/area-ledger.js.map +1 -1
  98. package/dist/sync/area-ledger.test.js +192 -5
  99. package/dist/sync/area-ledger.test.js.map +1 -1
  100. package/dist/sync/parallel-sync-coordinator.test.js +29 -0
  101. package/dist/sync/parallel-sync-coordinator.test.js.map +1 -1
  102. package/dist/sync/realtime-telemetry.d.ts +1 -1
  103. package/dist/sync/realtime-telemetry.d.ts.map +1 -1
  104. package/dist/sync/realtime-telemetry.js +14 -6
  105. package/dist/sync/realtime-telemetry.js.map +1 -1
  106. package/dist/sync/realtime-telemetry.test.js +136 -16
  107. package/dist/sync/realtime-telemetry.test.js.map +1 -1
  108. package/dist/sync/remote-file-spool.d.ts +16 -0
  109. package/dist/sync/remote-file-spool.d.ts.map +1 -0
  110. package/dist/sync/remote-file-spool.js +91 -0
  111. package/dist/sync/remote-file-spool.js.map +1 -0
  112. package/dist/sync/remote-file-spool.test.d.ts +2 -0
  113. package/dist/sync/remote-file-spool.test.d.ts.map +1 -0
  114. package/dist/sync/remote-file-spool.test.js +77 -0
  115. package/dist/sync/remote-file-spool.test.js.map +1 -0
  116. package/dist/sync/state-store.d.ts +6 -0
  117. package/dist/sync/state-store.d.ts.map +1 -1
  118. package/dist/sync/state-store.js +14 -2
  119. package/dist/sync/state-store.js.map +1 -1
  120. package/dist/watcher.d.ts +12 -0
  121. package/dist/watcher.d.ts.map +1 -1
  122. package/dist/watcher.js +131 -0
  123. package/dist/watcher.js.map +1 -1
  124. package/dist/watcher.test.js +63 -0
  125. package/dist/watcher.test.js.map +1 -1
  126. package/package.json +2 -1
package/dist/cli/sync.js CHANGED
@@ -6,6 +6,7 @@
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";
@@ -19,7 +20,7 @@ import { ensureCompanyVaultTransport } from "../company-vault-transport.js";
19
20
  import { createSyncProgressRecorder } from "../sync-progress.js";
20
21
  import { canonicalVaultKeySpelling, localPathForVaultKey, vaultKeyForLocalPath, } from "../local-path-codec.js";
21
22
  import { SKILLS_KEY_PREFIX } from "./doctor.js";
22
- import { downloadFile, listRemoteFiles, headRemoteFile, primeObjectTransport, toPosixKey, classifyVaultKey, DanglingSymlinkParentError, WindowsSymlinkPrivilegeError, WINDOWS_SYMLINK_PRIVILEGE_REMEDY, WindowsRenameBlockedError, WINDOWS_RENAME_BLOCKED_REMEDY, renameOrRaiseWindowsBlocked, } from "../s3.js";
23
+ import { downloadFile, streamRemoteFiles, headRemoteFile, primeObjectTransport, toPosixKey, classifyVaultKey, DanglingSymlinkParentError, WindowsSymlinkPrivilegeError, WINDOWS_SYMLINK_PRIVILEGE_REMEDY, WindowsRenameBlockedError, WINDOWS_RENAME_BLOCKED_REMEDY, renameOrRaiseWindowsBlocked, } from "../s3.js";
23
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";
24
25
  import { isGeneratedCoreMirrorKey, PERSONAL_VAULT_MANIFEST_KEY, } from "../personal-vault.js";
25
26
  import { isPersonalVaultExcluded } from "../personal-vault-exclusions.js";
@@ -188,6 +189,11 @@ export function targetedJournalSessionCensusSizes() {
188
189
  * dirty entries through a run-scoped journal writer.
189
190
  */
190
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;
191
197
  /**
192
198
  * Server cap on files per `/v1/notify/file-added` report. The endpoint rejects
193
199
  * an oversized batch wholesale, so the client MUST split a large report into
@@ -327,36 +333,53 @@ async function syncWithOperationLockHeld(options) {
327
333
  catch (err) {
328
334
  console.warn("hq-root-recovery: local HQ recovery failed:", err);
329
335
  }
330
- const run = await buildPullContext(options);
331
- try {
332
- const plan = planPull(run);
333
- emitPullPlan(run.emit, plan);
334
- reportInvalidRemoteKeys(run, plan);
335
- const scopeRun = run.keyedJournal
336
- ? emptyTargetedScopeRun(run)
337
- : executeScopeShrink(run, planScopeShrink(run));
338
- const counters = createPullCounters();
339
- const transferConcurrency = resolveTransferConcurrency(run.hostLoadAtStart);
340
- const conflictRun = await executeConflictExecutor(run, plan, scopeRun, counters);
341
- if (conflictRun.abortResult) {
342
- return conflictRun.abortResult;
343
- }
344
- await executeDownloadExecutor(run, conflictRun.downloadItems, transferConcurrency, counters);
345
- // The following added-by and tombstone phases may still make authenticated
346
- // vault calls. Make a completed sub-threshold download tail durable before
347
- // they begin, so their failure cannot turn a successful bulk transfer into
348
- // a full replay on the next pass.
349
- flushDownloadedJournalTail(run);
350
- await emitAndReportNewFiles(run, plan);
351
- await verifyPlannedJournalTombstones(run, plan);
352
- executeJournalTombstoneDeletes(run, plan, counters);
353
- emitScopeMaterializationGap(run, plan);
354
- return finalizePullRun(run, plan, scopeRun, counters);
355
- }
356
- finally {
357
- // Release a pass-local lease even on conflict, transfer, or auth failure.
358
- // The one bounded hot lease remains available only for its same vault.
359
- run.releaseKeyedJournal?.();
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
+ }
360
383
  }
361
384
  }
362
385
  async function buildPullContext(options) {
@@ -445,8 +468,9 @@ async function buildPullContext(options) {
445
468
  ? targetedJournalSession(journalSlug)
446
469
  : undefined;
447
470
  const keyedJournal = keyedJournalLease?.store;
471
+ let remoteFiles;
448
472
  try {
449
- const remoteFiles = await listRemoteFilesForScope(ctx, syncMode, listedPrefixSet, requestedPullPrefixes.length > 0);
473
+ remoteFiles = await listRemoteFilesForScope(ctx, syncMode, listedPrefixSet, requestedPullPrefixes.length > 0);
450
474
  const fileTombstones = options.fileTombstones ??
451
475
  await fetchFileTombstones(vaultConfig, options.personalMode === true
452
476
  ? { personal: true }
@@ -494,43 +518,27 @@ async function buildPullContext(options) {
494
518
  catch (error) {
495
519
  // `buildPullContext` can fail during LIST/freshness. Do not leave the
496
520
  // leased projection marked in use when no PullRunContext is returned.
521
+ if (remoteFiles && isStreamingRemoteFileSource(remoteFiles))
522
+ remoteFiles.dispose();
497
523
  keyedJournalLease?.release();
498
524
  throw error;
499
525
  }
500
526
  }
501
- /**
502
- * List the remote objects the current pull can actually materialize.
503
- *
504
- * Historically this was ALWAYS a full-bucket `listRemoteFiles(ctx)` — even for
505
- * a `shared`/`custom` membership whose effective scope is a handful of
506
- * prefixes — so every pull materialized the entire remote listing (tens of
507
- * thousands of RemoteFile objects on large vaults) only for `computePullPlan`
508
- * to discard most of it as `skip-out-of-scope`. Scoped pulls now list ONLY the
509
- * in-scope prefixes via `listRemoteForScope` (per-prefix ListObjectsV2 calls,
510
- * bounded parallel, deduped), which bounds the listing's memory to the scope's
511
- * actual footprint.
512
- *
513
- * `all`-mode (an unnarrowed full-vault pull, `listedPrefixSet` `[""]`) keeps
514
- * the single broad listing: fanning `all` out by top-level prefix would need a
515
- * delimiter LIST to discover the top-level names first and still union the
516
- * same total object count in one plan, so it bounds nothing today. (A
517
- * streaming planner is the real fix for `all` and is out of scope here.)
518
- *
519
- * An empty `listedPrefixSet` in a scoped mode means "nothing in scope" —
520
- * issue zero LIST calls. Consumers already treat an empty listing correctly:
521
- * the deletion sweep in `computePullPlan` only considers journal keys covered
522
- * by the LISTED scope, so keys absent because they were never listed can never
523
- * be mistaken for remote deletes.
524
- */
527
+ function isStreamingRemoteFileSource(source) {
528
+ return !Array.isArray(source);
529
+ }
530
+ function allModeRemoteFileStream(ctx) {
531
+ return spoolRemoteFiles(streamRemoteFiles(ctx));
532
+ }
525
533
  async function listRemoteFilesForScope(ctx, syncMode, listedPrefixSet, isTargetedPull) {
526
534
  const entries = toScopePrefixEntries(listedPrefixSet);
527
535
  if (entries.some((e) => e.prefix === ""))
528
- return listRemoteFiles(ctx);
536
+ return allModeRemoteFileStream(ctx);
529
537
  if (entries.length === 0) {
530
538
  // `all` mode always carries `[""]`, so an empty set only occurs for a
531
539
  // scoped mode (empty grants, or a targeted intersection that excluded
532
540
  // everything). Defensive `all` branch: never silently narrow a full pull.
533
- return syncMode === "all" ? listRemoteFiles(ctx) : [];
541
+ return syncMode === "all" ? allModeRemoteFileStream(ctx) : [];
534
542
  }
535
543
  return listRemoteForScope({
536
544
  ctx,
@@ -551,8 +559,14 @@ async function listRemoteFilesForScope(ctx, syncMode, listedPrefixSet, isTargete
551
559
  },
552
560
  });
553
561
  }
554
- function planPull(run) {
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);
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
+ }
556
570
  }
557
571
  /** Build only the rows a targeted remote LIST can observe. */
558
572
  function journalForTargetedPull(store, prefixes) {
@@ -665,9 +679,55 @@ function createPullCounters() {
665
679
  conflictPaths: [],
666
680
  changedPaths: [],
667
681
  appliedUpserts: [],
682
+ newFiles: [],
668
683
  hasRetryableOutcome: false,
669
684
  };
670
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
+ }
671
731
  function planScopeShrink(run) {
672
732
  const lastRecord = lastPullRecord(run.journal, run.ctx.uid);
673
733
  const lastPrefixSet = lastRecord && lastRecord.prefixSet.length > 0
@@ -1128,6 +1188,7 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
1128
1188
  if (resolution === "abort") {
1129
1189
  run.emit({ type: "new-files", files: [] });
1130
1190
  persistPullJournal(run);
1191
+ const abortedNewFiles = reportableNewFiles(run, plan);
1131
1192
  return {
1132
1193
  filesDownloaded: counters.filesDownloaded,
1133
1194
  bytesDownloaded: counters.bytesDownloaded,
@@ -1135,8 +1196,8 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
1135
1196
  conflicts: counters.conflicts,
1136
1197
  conflictPaths: counters.conflictPaths,
1137
1198
  aborted: true,
1138
- newFiles: plan.newFiles,
1139
- newFilesCount: plan.newFilesCount,
1199
+ newFiles: abortedNewFiles,
1200
+ newFilesCount: abortedNewFiles.length,
1140
1201
  filesExcludedByPolicy: plan.filesExcludedByPolicy,
1141
1202
  filesTombstoned: 0,
1142
1203
  filesOutOfScope: counters.filesOutOfScope,
@@ -1262,10 +1323,21 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
1262
1323
  discardProbe();
1263
1324
  }
1264
1325
  }
1265
- async function executeDownloadExecutor(run, downloadItems, transferConcurrency, counters) {
1266
- if (downloadItems.length === 0)
1267
- return;
1268
- await primeObjectTransport(run.ctx, "get", downloadItems.map((d) => d.remoteFile.key));
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) {
1269
1341
  const queue = [...downloadItems];
1270
1342
  const inFlight = new Set();
1271
1343
  const workerErrors = [];
@@ -1280,7 +1352,7 @@ async function executeDownloadExecutor(run, downloadItems, transferConcurrency,
1280
1352
  inFlight.size < transferConcurrency &&
1281
1353
  queue.length > 0) {
1282
1354
  const downloadItem = queue.shift();
1283
- const download = () => downloadOne(run, downloadItem, counters);
1355
+ const download = () => downloadOne(run, downloadItem, counters, deferJournalCheckpoint);
1284
1356
  const p = (run.options.transferSemaphore
1285
1357
  ? run.options.transferSemaphore.withPermit(download)
1286
1358
  : download())
@@ -1304,16 +1376,44 @@ async function executeDownloadExecutor(run, downloadItems, transferConcurrency,
1304
1376
  break;
1305
1377
  }
1306
1378
  }
1307
- if (workerErrors.length > 0) {
1308
- persistPullJournal(run);
1309
- const first = workerErrors[0];
1310
- if (workerErrors.length > 1) {
1311
- first.message = `${first.message} (and ${workerErrors.length - 1} more download-worker errors)`;
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));
1312
1413
  }
1313
- throw first;
1314
1414
  }
1315
1415
  }
1316
- async function downloadOne(run, downloadItem, counters) {
1416
+ async function downloadOne(run, downloadItem, counters, deferJournalCheckpoint = false) {
1317
1417
  const { remoteFile, localPath } = downloadItem;
1318
1418
  await refreshRunContextIfExpiring(run);
1319
1419
  if (!isDownloadWritePathStillContained(run.companyRoot, remoteFile.key, localPath)) {
@@ -1382,6 +1482,9 @@ async function downloadOne(run, downloadItem, counters) {
1382
1482
  counters.filesDownloaded++;
1383
1483
  counters.bytesDownloaded += size;
1384
1484
  counters.changedPaths.push(remoteFile.key);
1485
+ if (downloadItem.isNew) {
1486
+ counters.newFiles.push({ path: remoteFile.key, bytes: remoteFile.size });
1487
+ }
1385
1488
  // The event-push emitter hashes regular file bodies. Symlink journal hashes
1386
1489
  // describe link targets instead, and the startup baseline excludes them for
1387
1490
  // the same reason, so only hand regular-file downloads to that ledger.
@@ -1490,7 +1593,8 @@ async function downloadOne(run, downloadItem, counters) {
1490
1593
  return;
1491
1594
  }
1492
1595
  run.downloadsSinceJournalCheckpoint++;
1493
- if (run.downloadsSinceJournalCheckpoint >= DOWNLOAD_JOURNAL_CHECKPOINT_BATCH_SIZE) {
1596
+ if (!deferJournalCheckpoint &&
1597
+ run.downloadsSinceJournalCheckpoint >= DOWNLOAD_JOURNAL_CHECKPOINT_BATCH_SIZE) {
1494
1598
  flushDownloadedJournalTail(run);
1495
1599
  }
1496
1600
  }
@@ -1523,15 +1627,19 @@ function persistPullJournal(run) {
1523
1627
  }
1524
1628
  async function emitAndReportNewFiles(run, plan) {
1525
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);
1526
1634
  // Batch-mint the GET presigns once (chunked, breaker-aware) so the per-file
1527
1635
  // created-by HEADs below reuse the cache instead of each minting its own
1528
1636
  // presign. Without this, a big catch-up pull (hundreds of new files) bursts
1529
1637
  // the presign endpoint, trips the circuit breaker, and every enrichment HEAD
1530
1638
  // then fails. Mirrors the tombstone HEAD-verify pre-prime.
1531
- await primeObjectTransport(run.ctx, "get", plan.newFiles.map((nf) => nf.path));
1639
+ await primeObjectTransport(run.ctx, "get", newFiles.map((nf) => nf.path));
1532
1640
  const HEAD_CONCURRENCY = 5;
1533
- for (let i = 0; i < plan.newFiles.length; i += HEAD_CONCURRENCY) {
1534
- const batch = plan.newFiles.slice(i, i + HEAD_CONCURRENCY);
1641
+ for (let i = 0; i < newFiles.length; i += HEAD_CONCURRENCY) {
1642
+ const batch = newFiles.slice(i, i + HEAD_CONCURRENCY);
1535
1643
  const results = await Promise.all(batch.map(async (nf) => {
1536
1644
  let addedBy = null;
1537
1645
  try {
@@ -1712,6 +1820,7 @@ function finalizePullRun(run, plan, scopeRun, counters) {
1712
1820
  // best-effort: a post-sync refresh failure never fails the sync
1713
1821
  }
1714
1822
  }
1823
+ const newFiles = reportableNewFiles(run, plan);
1715
1824
  const result = {
1716
1825
  filesDownloaded: counters.filesDownloaded,
1717
1826
  bytesDownloaded: counters.bytesDownloaded,
@@ -1719,8 +1828,8 @@ function finalizePullRun(run, plan, scopeRun, counters) {
1719
1828
  conflicts: counters.conflicts,
1720
1829
  conflictPaths: counters.conflictPaths,
1721
1830
  aborted: false,
1722
- newFiles: plan.newFiles,
1723
- newFilesCount: plan.newFilesCount,
1831
+ newFiles,
1832
+ newFilesCount: newFiles.length,
1724
1833
  filesExcludedByPolicy: plan.filesExcludedByPolicy,
1725
1834
  filesTombstoned: counters.filesTombstoned,
1726
1835
  filesOutOfScope: counters.filesOutOfScope,
@@ -1949,7 +2058,8 @@ class LocalSnapshotChangedError extends Error {
1949
2058
  * the real outcome of the upcoming Stage-2 execution rather than an
1950
2059
  * upper-bound guess.
1951
2060
  *
1952
- * Pure function: no S3 calls, no journal writes, no event emission. The
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
1953
2063
  * caller (`sync()`) is responsible for emitting the resulting plan event
1954
2064
  * before iterating `items`.
1955
2065
  */
@@ -2017,6 +2127,36 @@ excludePrefixes = [],
2017
2127
  // callers preserve the pre-policy, watcher-intent-only behavior.
2018
2128
  propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows(journal).size) {
2019
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
+ }
2020
2160
  // Remote keys the server will never presign — collected so the caller can
2021
2161
  // warn + emit telemetry without allowing one poison object to fail a pull.
2022
2162
  const invalidRemoteKeys = [];
@@ -2032,8 +2172,35 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
2032
2172
  // never gated by this map (see the guard below), so pre-existing families
2033
2173
  // keep syncing untouched until the vault doctor (US-003) collapses them.
2034
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();
2035
2187
  for (const journaledKey of journalRows(journal).keys()) {
2036
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
+ }
2037
2204
  if (!journaledKey.startsWith(SKILLS_KEY_PREFIX))
2038
2205
  continue;
2039
2206
  const canonical = canonicalVaultKeySpelling(journaledKey);
@@ -2041,6 +2208,15 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
2041
2208
  if (existing === undefined || journaledKey === canonical) {
2042
2209
  journaledKeyByCanonical.set(canonical, journaledKey);
2043
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
+ }
2044
2220
  }
2045
2221
  // Remote keys that ALSO appear as an ancestor of another remote key — i.e.
2046
2222
  // keys that carry child objects under `${key}/…` in this same LIST. The
@@ -2050,22 +2226,40 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
2050
2226
  // objects beneath it. A REAL directory, by contrast, only ever syncs as
2051
2227
  // those child objects — never as a single object AT its own key. So a single
2052
2228
  // remote object sitting at a key that also has children is unambiguously a
2053
- // directory-overlay marker, not a regular file. Precomputed once (pure path
2054
- // derivation, no I/O the planner is synchronous) so the dir-vs-object
2055
- // collision branch can tell an inert overlay marker apart from a genuine
2056
- // file-vs-directory structural collision. feedback_d2082110.
2057
- const remoteKeysWithChildren = new Set();
2058
- for (const rf of remoteFiles) {
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) {
2059
2239
  onHeartbeat?.();
2060
- const key = toPosixKey(rf.key);
2061
- let slash = key.indexOf("/");
2062
- while (slash !== -1) {
2063
- remoteKeysWithChildren.add(key.slice(0, slash));
2064
- slash = key.indexOf("/", slash + 1);
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
+ }
2065
2262
  }
2066
- }
2067
- for (const remoteFile of remoteFiles) {
2068
- onHeartbeat?.();
2069
2263
  const invalidKey = classifyVaultKey(remoteFile.key, personalMode ? "personal" : "company");
2070
2264
  if (invalidKey !== null) {
2071
2265
  invalidRemoteKeys.push({
@@ -2718,6 +2912,9 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
2718
2912
  filesToSkip++;
2719
2913
  }
2720
2914
  }
2915
+ filesToSkip += preplannedFilesSkipped;
2916
+ filesExcludedByPolicy += preplannedFilesExcludedByPolicy;
2917
+ filesOutOfScope += preplannedFilesOutOfScope;
2721
2918
  // Bug #9 — cross-machine delete propagation. The peer's push leg
2722
2919
  // already removed the file from S3 (verified in the deep-test
2723
2920
  // addendum: `aws s3 ls` showed the keys absent post-push). Pre-fix
@@ -2726,38 +2923,21 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
2726
2923
  // that-was-there-before" — so the file stayed forever on every
2727
2924
  // receiver and `filesTombstoned: 0` showed up on every pull.
2728
2925
  //
2729
- // Closing the loop: walk the journal, find every entry whose key is
2730
- // NOT in the remote LIST set, AND whose path passes the current
2926
+ // Closing the loop: walk journal keys the streamed LIST did NOT clear, AND
2927
+ // whose path passes the current
2731
2928
  // ignore filter (paths newly excluded by .hqignore must not trigger
2732
2929
  // mass-delete), AND — in personalMode — survives the same companies/*
2733
2930
  // gating the download branch applies. The executor will apply each
2734
2931
  // as a local delete + journal removal. Symmetric to the push side's
2735
2932
  // `propagateDeletes` plan in share.ts.
2736
- const remoteKeySet = new Set();
2737
- // Skill-farm twin protection (companion to the junk-spelling pull guard):
2738
- // when the LIST holds only a junk spelling of a journaled canonical key,
2739
- // the logical object is still remotely present — a raw has() would class
2740
- // the canonical journal entry as remote-deleted, unlink the good local
2741
- // wrapper, and drop the journal entry (after which the next pull would
2742
- // materialize the junk spelling the guard just refused). Track canonical
2743
- // forms of listed skill keys and consult them below.
2744
- const remoteCanonicalSkillKeys = new Set();
2745
- for (const rf of remoteFiles) {
2746
- remoteKeySet.add(rf.key);
2747
- if (rf.key.startsWith(SKILLS_KEY_PREFIX)) {
2748
- remoteCanonicalSkillKeys.add(canonicalVaultKeySpelling(rf.key));
2749
- }
2750
- }
2751
2933
  const tombstones = [];
2752
- for (const key of journalRows(journal).keys()) {
2934
+ for (const key of unseenJournalKeys) {
2753
2935
  // Compare membership in POSIX space. A pre-5.47.2 Windows journal key can
2754
2936
  // carry backslash separators; the remote LIST is always forward-slash, so a
2755
2937
  // raw `has(key)` would miss the match and class a live local file as
2756
2938
  // remote-deleted. normalizeJournalKeys() defuses this on load, but the
2757
2939
  // POSIX compare is defense-in-depth (ridge data-loss, feedback_b8d09d0f).
2758
2940
  const posixKey = toPosixKey(key);
2759
- if (remoteKeySet.has(posixKey))
2760
- continue;
2761
2941
  // Listing-scope guard (targeted sync / scoped listing). The remote LIST
2762
2942
  // now covers ONLY `prefixSet` (scoped modes list per-prefix; a targeted
2763
2943
  // pull narrows further), so "absent from the LIST" is evidence of a remote
@@ -2769,16 +2949,6 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
2769
2949
  // `all` mode passes `[""]`, which covers every key — no behavior change.
2770
2950
  if (!isCoveredByAny(posixKey, prefixSet))
2771
2951
  continue;
2772
- if (posixKey.startsWith(SKILLS_KEY_PREFIX) &&
2773
- remoteCanonicalSkillKeys.has(canonicalVaultKeySpelling(posixKey)) &&
2774
- // An EXACT FILE_TOMBSTONE for the journaled key is an authoritative
2775
- // delete and wins over the twin protection — a lingering junk
2776
- // spelling in the LIST must not keep a deliberately-deleted wrapper
2777
- // alive forever. (The key is absent from the LIST here, so there is
2778
- // no newer re-create to defer to.)
2779
- fileTombstones.get(posixKey) === undefined) {
2780
- continue;
2781
- }
2782
2952
  // Fast-path ignore, BEFORE containment canonicalization. The tombstone
2783
2953
  // sweep runs for every journaled key, and a stale journal can carry
2784
2954
  // hundreds of thousands of entries under an ignored runtime tree (e.g.
@@ -2887,6 +3057,8 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
2887
3057
  }
2888
3058
  return {
2889
3059
  items,
3060
+ preplannedFilesSkipped,
3061
+ preplannedFilesOutOfScope,
2890
3062
  filesToDownload,
2891
3063
  bytesToDownload,
2892
3064
  filesToSkip,