@indigoai-us/hq-cloud 6.18.94 → 6.18.95

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 (81) hide show
  1. package/dist/bin/sync-runner.d.ts +20 -1
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +25 -0
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/cli/reindex.d.ts +24 -0
  6. package/dist/cli/reindex.d.ts.map +1 -1
  7. package/dist/cli/reindex.js +26 -1
  8. package/dist/cli/reindex.js.map +1 -1
  9. package/dist/cli/share.d.ts +28 -5
  10. package/dist/cli/share.d.ts.map +1 -1
  11. package/dist/cli/share.js +142 -19
  12. package/dist/cli/share.js.map +1 -1
  13. package/dist/cli/sync.d.ts +11 -1
  14. package/dist/cli/sync.d.ts.map +1 -1
  15. package/dist/cli/sync.js +56 -7
  16. package/dist/cli/sync.js.map +1 -1
  17. package/dist/hq-root-recovery.d.ts +37 -6
  18. package/dist/hq-root-recovery.d.ts.map +1 -1
  19. package/dist/hq-root-recovery.js +266 -22
  20. package/dist/hq-root-recovery.js.map +1 -1
  21. package/dist/journal.d.ts +11 -1
  22. package/dist/journal.d.ts.map +1 -1
  23. package/dist/journal.js +23 -2
  24. package/dist/journal.js.map +1 -1
  25. package/dist/lib/cloud-authoritative.d.ts +10 -0
  26. package/dist/lib/cloud-authoritative.d.ts.map +1 -1
  27. package/dist/lib/cloud-authoritative.js +23 -0
  28. package/dist/lib/cloud-authoritative.js.map +1 -1
  29. package/dist/manifest-reconcile.d.ts +14 -0
  30. package/dist/manifest-reconcile.d.ts.map +1 -1
  31. package/dist/manifest-reconcile.js +20 -2
  32. package/dist/manifest-reconcile.js.map +1 -1
  33. package/dist/outposts/client.d.ts +6 -1
  34. package/dist/outposts/client.d.ts.map +1 -1
  35. package/dist/outposts/client.js +13 -1
  36. package/dist/outposts/client.js.map +1 -1
  37. package/dist/outposts/command.d.ts +1 -1
  38. package/dist/outposts/command.d.ts.map +1 -1
  39. package/dist/outposts/command.js +58 -2
  40. package/dist/outposts/command.js.map +1 -1
  41. package/dist/outposts/company.d.ts +15 -1
  42. package/dist/outposts/company.d.ts.map +1 -1
  43. package/dist/outposts/company.js +36 -1
  44. package/dist/outposts/company.js.map +1 -1
  45. package/dist/s3.d.ts +22 -0
  46. package/dist/s3.d.ts.map +1 -1
  47. package/dist/s3.js +44 -0
  48. package/dist/s3.js.map +1 -1
  49. package/dist/session-log-capture.d.ts.map +1 -1
  50. package/dist/session-log-capture.js +5 -2
  51. package/dist/session-log-capture.js.map +1 -1
  52. package/dist/sync/area-ledger.d.ts.map +1 -1
  53. package/dist/sync/area-ledger.js +3 -3
  54. package/dist/sync/area-ledger.js.map +1 -1
  55. package/dist/sync/durable-apply.js +2 -2
  56. package/dist/sync/durable-apply.js.map +1 -1
  57. package/dist/sync/per-area-maintenance.js +2 -2
  58. package/dist/sync/per-area-maintenance.js.map +1 -1
  59. package/dist/sync/reconcile-cursor.js +2 -2
  60. package/dist/sync/reconcile-cursor.js.map +1 -1
  61. package/dist/sync/secret-path-policy.d.ts +37 -11
  62. package/dist/sync/secret-path-policy.d.ts.map +1 -1
  63. package/dist/sync/secret-path-policy.js +68 -34
  64. package/dist/sync/secret-path-policy.js.map +1 -1
  65. package/dist/sync/snapshot-client.js +2 -2
  66. package/dist/sync/snapshot-client.js.map +1 -1
  67. package/dist/sync/state-store.d.ts +19 -0
  68. package/dist/sync/state-store.d.ts.map +1 -1
  69. package/dist/sync/state-store.js +184 -4
  70. package/dist/sync/state-store.js.map +1 -1
  71. package/dist/sync/uploader-finalization.d.ts.map +1 -1
  72. package/dist/sync/uploader-finalization.js +2 -2
  73. package/dist/sync/uploader-finalization.js.map +1 -1
  74. package/dist/test-fixtures/unknown-journal-generation.d.ts +9 -0
  75. package/dist/test-fixtures/unknown-journal-generation.d.ts.map +1 -0
  76. package/dist/test-fixtures/unknown-journal-generation.js +37 -0
  77. package/dist/test-fixtures/unknown-journal-generation.js.map +1 -0
  78. package/dist/watcher.d.ts.map +1 -1
  79. package/dist/watcher.js +3 -3
  80. package/dist/watcher.js.map +1 -1
  81. package/package.json +1 -1
package/dist/cli/share.js CHANGED
@@ -23,10 +23,10 @@ import { uploadFile, uploadSymlink, toPosixKey, headRemoteFile, readVerifiedRemo
23
23
  import * as crypto from "crypto";
24
24
  import { bindObjectIOToContext, PresignObjectIO, RateLimitedError, resolveObjectIO, S3SdkObjectIO, } from "../object-io.js";
25
25
  import { primePrivateFolderUpload } from "../private-folder-transport.js";
26
- import { getJournalPath, isUnderAnyRoot, readJournalForPass, readJournalScoped, readQuarantinedJournalPaths, writeJournal, setJournalSnapshotQuarantineListener, hashFile, hashSymlinkTarget, journalRows, journalEntryMatchesStat, journalFingerprintMatchesStat, dropJournalDecodeWindow, timestampsTooCoarseToTrust, updateEntry, PrematureJournalEntryError, removeEntry, isTombstone, normalizeEtag, PERSONAL_VAULT_JOURNAL_SLUG, migratePersonalVaultJournal, migrateToV2, } from "../journal.js";
27
- import { createIgnoreExplainer, createIgnoreFilter, hasControlCharacters, isExpectedIgnore, isMachineLocalRuntimePath, isPathExcludedFromSync, isSecretPatternIgnore, isWithinSizeLimit, } from "../ignore.js";
26
+ import { getJournalPath, isUnderAnyRoot, readJournalForPass, readJournalReadOnly, readJournalScoped, readQuarantinedJournalPaths, writeJournal, setJournalSnapshotQuarantineListener, hashFile, hashSymlinkTarget, journalRows, journalEntryMatchesStat, journalFingerprintMatchesStat, dropJournalDecodeWindow, timestampsTooCoarseToTrust, updateEntry, PrematureJournalEntryError, removeEntry, isTombstone, normalizeEtag, PERSONAL_VAULT_JOURNAL_SLUG, migratePersonalVaultJournal, migrateToV2, configureRejectedDeltaRecovery, getJournalRecoveryMarker, } from "../journal.js";
27
+ import { createIgnoreExplainer, createIgnoreFilter, hasControlCharacters, isExpectedIgnore, isHqGeneratedIndexFile, isMachineLocalRuntimePath, isPathExcludedFromSync, isSecretFolderPath, isSecretPatternIgnore, isWithinSizeLimit, } from "../ignore.js";
28
28
  import { wrapFilterWithPersonalVaultDefaults, } from "../personal-vault-exclusions.js";
29
- import { claimNeverSyncedLocalNotice, formatSecretsNotSyncedLine, isNeverSyncedVaultPath, isSecretBearingVaultPath, isSkipSecretPathsEnabled, } from "../sync/secret-path-policy.js";
29
+ import { claimNeverSyncedLocalNotices, formatSecretsNotSyncedLine, isNeverSyncedVaultPath, isSecretBearingVaultPath, isSkipSecretPathsEnabled, isWalRejectedDeltaRecoveryEnabled, } from "../sync/secret-path-policy.js";
30
30
  import { getActiveFullPassDecodeWindow, getActiveFullPassUnitSize, } from "../sync/full-pass-caps.js";
31
31
  import { isGeneratedCoreMirrorKey, isPersonalVaultDeletionExcluded, isSessionLogDeletionKey, purgeExcludedPersonalVaultJournalEntries, } from "../personal-vault.js";
32
32
  import { pickWinner, resolveConflict, usesVersionAwareWinner } from "./conflict.js";
@@ -885,10 +885,13 @@ export class UnreachablePushPathsError extends Error {
885
885
  paths;
886
886
  /** Per-path reason, keyed by the same original spelling. */
887
887
  reasons;
888
- constructor(unreachable, syncRoot) {
888
+ constructor(unreachable, syncRoot, ignoreRules = new Map()) {
889
889
  const paths = [...unreachable.keys()];
890
890
  const lines = paths.map((p) => {
891
891
  const reason = unreachable.get(p);
892
+ if (reason === "ignored") {
893
+ return ` · ${p} — ${ignoreRules.get(p) ?? "excluded by a sync ignore rule"}; it was not uploaded`;
894
+ }
892
895
  if (reason === "outside-company") {
893
896
  return ` · ${p} — resolves outside the company folder (${syncRoot})`;
894
897
  }
@@ -1367,6 +1370,10 @@ async function createPushRunContext(options) {
1367
1370
  ? await isSkipSecretPathsEnabled(secretPathFlagConfig, ctx.uid, true)
1368
1371
  : await isSkipSecretPathsEnabled(secretPathFlagConfig, ctx.uid)
1369
1372
  : preVendedCompanyPush;
1373
+ const walRecoveryEnabled = secretPathFlagConfig
1374
+ ? await isWalRejectedDeltaRecoveryEnabled(secretPathFlagConfig, ctx.uid)
1375
+ : true;
1376
+ configureRejectedDeltaRecovery(walRecoveryEnabled);
1370
1377
  const ignoreFilter = createIgnoreFilter(hqRoot, {
1371
1378
  allowRootMcpConfig: personalConnectorSettingsSync,
1372
1379
  });
@@ -1429,8 +1436,7 @@ async function createPushRunContext(options) {
1429
1436
  return false;
1430
1437
  return baseFilter(filePath, isDir);
1431
1438
  };
1432
- const neverSyncedLocalPath = claimNeverSyncedLocalNotice(hqRoot, syncRoot);
1433
- if (neverSyncedLocalPath) {
1439
+ for (const neverSyncedLocalPath of claimNeverSyncedLocalNotices(hqRoot, syncRoot)) {
1434
1440
  emit({ type: "secrets-not-synced", path: neverSyncedLocalPath });
1435
1441
  }
1436
1442
  const shouldSync = options.prefixSet !== undefined
@@ -1453,6 +1459,7 @@ async function createPushRunContext(options) {
1453
1459
  // known. Full/cadence callers retain the complete snapshot here.
1454
1460
  const scopedJournal = options.scopedJournal === true;
1455
1461
  const journal = scopedJournal ? emptyJournalForScopedPass() : readJournalForPass(journalSlug, ctx.uid);
1462
+ const holdRemoteDeletes = getJournalRecoveryMarker(journalSlug) !== undefined;
1456
1463
  return {
1457
1464
  options,
1458
1465
  ontologyEntityPush: false,
@@ -1481,7 +1488,15 @@ async function createPushRunContext(options) {
1481
1488
  syncRoot,
1482
1489
  shouldSync,
1483
1490
  durableShouldSync: baseFilter,
1491
+ ignoreFilter,
1492
+ explainIgnore: (filePath, isDir) => {
1493
+ explainIgnored ??= createIgnoreExplainer(hqRoot, {
1494
+ allowRootMcpConfig: personalConnectorSettingsSync,
1495
+ });
1496
+ return explainIgnored(filePath, isDir);
1497
+ },
1484
1498
  skipSecretPaths,
1499
+ holdRemoteDeletes,
1485
1500
  journalSlug,
1486
1501
  quarantinedPaths,
1487
1502
  journal,
@@ -1489,12 +1504,14 @@ async function createPushRunContext(options) {
1489
1504
  excludedSet,
1490
1505
  excludedById,
1491
1506
  scopeExcludedSet,
1507
+ uploadFailedSet: new Set(),
1492
1508
  ignoreExcludedSet,
1493
1509
  ignoreExcludedTotal,
1494
1510
  ignoreExcludedLayers,
1495
1511
  ignoreIncludeLines,
1496
1512
  secretExcludedSet,
1497
1513
  unreachablePaths,
1514
+ ignoredNamedPathRules: new Map(),
1498
1515
  linkedSubtreeSet,
1499
1516
  skillRegistrationConflicts,
1500
1517
  hostLoadAtStart,
@@ -2184,6 +2201,7 @@ async function buildSharePlans(run) {
2184
2201
  if (rememberLinkWarning(run.linkWarnings, warning))
2185
2202
  run.emit(brokenLinkSkipEvent(warning));
2186
2203
  },
2204
+ onFilteredNamedPath: (namedPath, absolutePath, isDir) => recordIgnoredNamedPath(run, namedPath, absolutePath, isDir),
2187
2205
  onLinkedSubtree: (rel) => run.linkedSubtreeSet.add(rel),
2188
2206
  onHeartbeat: run.options.onHeartbeat,
2189
2207
  cooperativeCheckpoint: run.options.cooperativeCheckpoint,
@@ -2200,7 +2218,7 @@ async function buildSharePlans(run) {
2200
2218
  const fatal = collectFatalUnreachablePaths(run);
2201
2219
  if (fatal.size > 0) {
2202
2220
  emitUnreachablePathEvent(run);
2203
- throw new UnreachablePushPathsError(fatal, run.syncRoot);
2221
+ throw new UnreachablePushPathsError(fatal, run.syncRoot, run.ignoredNamedPathRules);
2204
2222
  }
2205
2223
  const deleteScopeRoots = run.propagateDeletes === true
2206
2224
  ? resolveDeleteScopeRoots(run.paths, run.hqRoot, run.syncRoot, run.options.deleteScopeRoots)
@@ -2291,8 +2309,9 @@ async function buildSharePlans(run) {
2291
2309
  companySlug: run.ctx.slug,
2292
2310
  reportedUnsafeSymlinkPaths: run.reportedUnsafeSymlinkPaths,
2293
2311
  });
2312
+ const recoveryHoldActive = run.holdRemoteDeletes || getJournalRecoveryMarker(run.journalSlug) !== undefined;
2294
2313
  const deletePlan = run.propagateDeletes === true
2295
- ? await computeDeletePlan(run.journal, run.syncRoot, deleteScopeRoots, run.shouldSync, run.propagateDeletePolicy, run.ctx, run.options.personalMode !== true, run.skipSecretPaths, run.quarantinedPaths)
2314
+ ? await computeDeletePlan(run.journal, run.syncRoot, deleteScopeRoots, run.shouldSync, run.propagateDeletePolicy, run.ctx, run.options.personalMode !== true, run.skipSecretPaths, run.quarantinedPaths, recoveryHoldActive, run.scopedJournal ? () => readJournalReadOnly(run.journalSlug) : undefined)
2296
2315
  : { toDelete: [], toTombstone: [], refusedStale: [] };
2297
2316
  for (const warning of deletePlan.brokenLinkSkipped ?? []) {
2298
2317
  if (rememberLinkWarning(run.linkWarnings, warning))
@@ -3174,6 +3193,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3174
3193
  if (outcome === "write-failed") {
3175
3194
  // The merge already emitted the error. A second skip line repeats it.
3176
3195
  hadUploadFailure = true;
3196
+ run.uploadFailedSet.add(relativePath);
3177
3197
  }
3178
3198
  counters.filesSkipped++;
3179
3199
  return;
@@ -3232,6 +3252,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3232
3252
  if (outcome === "write-failed") {
3233
3253
  // The merge already emitted the error. A second skip line repeats it.
3234
3254
  hadUploadFailure = true;
3255
+ run.uploadFailedSet.add(relativePath);
3235
3256
  }
3236
3257
  counters.filesSkipped++;
3237
3258
  return;
@@ -3710,6 +3731,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3710
3731
  if (outcome === "write-failed") {
3711
3732
  // The merge already emitted the error. A second skip line repeats it.
3712
3733
  hadUploadFailure = true;
3734
+ run.uploadFailedSet.add(relativePath);
3713
3735
  }
3714
3736
  else if (outcome === "publish-local") {
3715
3737
  counters.filesSkippedConflict++;
@@ -3788,6 +3810,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3788
3810
  return;
3789
3811
  }
3790
3812
  hadUploadFailure = true;
3813
+ run.uploadFailedSet.add(relativePath);
3791
3814
  run.emit({
3792
3815
  type: "error",
3793
3816
  path: relativePath,
@@ -3890,6 +3913,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3890
3913
  if (emitPlanLimitSkip(retryErr, relativePath))
3891
3914
  return;
3892
3915
  hadUploadFailure = true;
3916
+ run.uploadFailedSet.add(relativePath);
3893
3917
  run.emit({
3894
3918
  type: "error",
3895
3919
  path: relativePath,
@@ -3939,6 +3963,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3939
3963
  });
3940
3964
  }
3941
3965
  else {
3966
+ run.uploadFailedSet.add(relativePath);
3942
3967
  run.emit({
3943
3968
  type: "error",
3944
3969
  path: relativePath,
@@ -5210,11 +5235,59 @@ function collectFatalUnreachablePaths(run) {
5210
5235
  if (run.options.unreachablePathPolicy === "warn")
5211
5236
  return fatal;
5212
5237
  for (const [namedPath, reason] of run.unreachablePaths) {
5213
- if (reason === "outside-company")
5238
+ if (reason === "outside-company" || reason === "ignored")
5214
5239
  fatal.set(namedPath, reason);
5215
5240
  }
5216
5241
  return fatal;
5217
5242
  }
5243
+ /**
5244
+ * A caller-named path that the ignore stack rejects was dropped with no event:
5245
+ * `.hq-*` names and generated INDEX.md files count as expected noise, so the
5246
+ * ignore warning skips them, and the push printed "✓ Pushed 0 file(s)" with
5247
+ * exit 0 (feedback_63bfc302, feedback_8c294805). Record it as `"ignored"` so
5248
+ * the default policy fails the push and names the rule.
5249
+ *
5250
+ * Not recorded:
5251
+ * - Runs without `failOnIgnoredNamedPaths`. Their paths are enumerated roots,
5252
+ * not user input, and an ignored root there is routine.
5253
+ * - `"warn"` runs. Their paths are watcher and walk roots, where an ignored
5254
+ * file is routine and already filtered.
5255
+ * - Paths the raw ignore stack admits. Scope, personal-vault, and secret-path
5256
+ * wrappers rejected those and report them through their own events.
5257
+ * - Secret-pattern files (dotenv files, credentials) and secret folders
5258
+ * (company `secrets/` and `settings/`, `.secrets`). They are left out by
5259
+ * design (US-178), and watcher runs name them routinely.
5260
+ */
5261
+ function recordIgnoredNamedPath(run, namedPath, absolutePath, isDir) {
5262
+ if (run.options.failOnIgnoredNamedPaths !== true)
5263
+ return;
5264
+ if (run.options.unreachablePathPolicy === "warn")
5265
+ return;
5266
+ if (run.unreachablePaths.has(namedPath))
5267
+ return;
5268
+ if (run.ignoreFilter(absolutePath, isDir))
5269
+ return;
5270
+ const rel = vaultKeyForLocalPath(run.hqRoot, absolutePath);
5271
+ if (isSecretFolderPath(rel))
5272
+ return;
5273
+ if (!isDir && isSecretPatternIgnore(rel))
5274
+ return;
5275
+ run.unreachablePaths.set(namedPath, "ignored");
5276
+ run.ignoredNamedPathRules.set(namedPath, describeIgnoreRule(run, absolutePath, isDir));
5277
+ }
5278
+ function describeIgnoreRule(run, absolutePath, isDir) {
5279
+ if (!isDir && isHqGeneratedIndexFile(absolutePath)) {
5280
+ return "an HQ-generated INDEX.md (it starts with \"> Auto-generated.\"), which each machine rebuilds and sync never uploads";
5281
+ }
5282
+ const { layer } = run.explainIgnore(absolutePath, isDir);
5283
+ if (layer === ".hqignore" || layer === ".hqsyncignore") {
5284
+ return `excluded by a rule in ${layer}`;
5285
+ }
5286
+ if (layer === ".hqinclude allowlist") {
5287
+ return "not listed in .hqinclude";
5288
+ }
5289
+ return "excluded by a built-in sync ignore rule";
5290
+ }
5218
5291
  /**
5219
5292
  * Emit the `not-shipped` / `unreachable-path` event for a run, if any named
5220
5293
  * path went unshipped. Shared by BOTH policies so the operator sees the same
@@ -5311,6 +5384,8 @@ function buildShareResult(run, counters, filesRefusedStalePaths, conflictPaths,
5311
5384
  filesTombstoned: counters.filesTombstoned,
5312
5385
  filesRefusedStale: counters.filesRefusedStale,
5313
5386
  filesRefusedStalePaths,
5387
+ filesUploadFailed: run.uploadFailedSet.size,
5388
+ uploadFailedPaths: [...run.uploadFailedSet].slice(0, REFUSED_STALE_PATH_CAP),
5314
5389
  filesSuppressedByTombstone: counters.filesSuppressedByTombstone,
5315
5390
  ...(run.skillRegisterLandedBytes
5316
5391
  ? { skillRegistrationFallbacks: run.skillRegistrationFallbacks }
@@ -5448,6 +5523,9 @@ function defaultConsoleLogger(event, humanSkipLines) {
5448
5523
  console.error(` ⚠ cloud-placeholder, kept on remote: ${event.path} ` +
5449
5524
  `(a .hqcloud placeholder stands in for this file, so it was not deleted)`);
5450
5525
  }
5526
+ else if (event.reason === "state-rebuilt-deletes-held") {
5527
+ console.error(` ⚠ state-rebuilt-deletes-held, kept on remote: ${event.path} (complete a full authoritative sync pass to release the hold)`);
5528
+ }
5451
5529
  else if (event.reason === "marker-mismatch") {
5452
5530
  console.error(` ⚠ marker-mismatch, kept on remote: ${event.path} ` +
5453
5531
  `(the remote object is not the symlink revision reindex replaced)`);
@@ -5895,8 +5973,10 @@ async function collectFiles(paths, hqRoot, syncRoot, filter, hooks = {}) {
5895
5973
  // symlinks under allowlist mode (the motivating case for this
5896
5974
  // whole branch). The filter is pure path lookup with no I/O,
5897
5975
  // so two calls are free.
5898
- if (!filter(absolutePath, false) && !filter(absolutePath, true))
5976
+ if (!filter(absolutePath, false) && !filter(absolutePath, true)) {
5977
+ hooks.onFilteredNamedPath?.(p, absolutePath, false);
5899
5978
  continue;
5979
+ }
5900
5980
  const target = readlinkOrNull(absolutePath);
5901
5981
  if (target === null) {
5902
5982
  console.error(` Warning: ${p} is an unreadable symbolic link, skipping.`);
@@ -5924,17 +6004,22 @@ async function collectFiles(paths, hqRoot, syncRoot, filter, hooks = {}) {
5924
6004
  continue;
5925
6005
  }
5926
6006
  if (lstat.isDirectory()) {
5927
- if (!filter(absolutePath, true))
6007
+ if (!filter(absolutePath, true)) {
6008
+ hooks.onFilteredNamedPath?.(p, absolutePath, true);
5928
6009
  continue;
6010
+ }
5929
6011
  await walkDir(absolutePath, syncRoot, filter, results, hooks, cooperativeWork);
5930
6012
  }
5931
6013
  else if (lstat.isFile()) {
5932
6014
  const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
6015
+ if (!filter(absolutePath)) {
6016
+ hooks.onFilteredNamedPath?.(p, absolutePath, false);
6017
+ continue;
6018
+ }
5933
6019
  // Cloud-only placeholders are local stand-ins; never upload them,
5934
6020
  // except library asset placeholders, which sync as small text files.
5935
- if (filter(absolutePath) &&
5936
- (!isCloudPlaceholderName(path.basename(absolutePath)) ||
5937
- isSyncedCloudPlaceholderFile(absolutePath))) {
6021
+ if (!isCloudPlaceholderName(path.basename(absolutePath)) ||
6022
+ isSyncedCloudPlaceholderFile(absolutePath)) {
5938
6023
  noteCooperativeRowVisit("collect", relativePath);
5939
6024
  results.push({ kind: "file", absolutePath, relativePath });
5940
6025
  }
@@ -6232,6 +6317,13 @@ function resolveDeleteScopeRoots(paths, hqRoot, syncRoot, explicitRoots = []) {
6232
6317
  * already opts out of safety gates (currency check, owned-only).
6233
6318
  * This guard honors that explicit opt-out.
6234
6319
  *
6320
+ * `inScope` in a watcher (scoped) push is NOT the delete roots alone. The
6321
+ * watcher names each deleted key as its own delete root, so counting only
6322
+ * rows under those roots makes the ratio ~100% and refuses every batch of
6323
+ * MIN_ABS or more deletes, which the next pull then restores. When the local
6324
+ * ratio trips, a scoped push re-counts the denominator over the whole journal
6325
+ * (`bulkBaselineJournal`), the same rows a full pass's root would cover.
6326
+ *
6235
6327
  * `"owned-only"` and `"currency-gated"` both go through the guard. The
6236
6328
  * `direction === "up"` filter in `owned-only` is no defense once a user
6237
6329
  * has ever run full bidirectional sync (every uploaded file gets
@@ -6388,7 +6480,10 @@ export async function computeDeletePlan(journal, syncRoot, scopeRoots, shouldSyn
6388
6480
  // True for a COMPANY-scoped push (personalMode !== true). This distinguishes
6389
6481
  // the canonical knowledge-marker layout from the personal-vault layout; it
6390
6482
  // does not reinterpret ordinary vault-relative path segments.
6391
- companyScoped = true, skipSecretPaths = false, excludedPaths = new Set()) {
6483
+ companyScoped = true, skipSecretPaths = false, excludedPaths = new Set(), holdRemoteDeletes = false,
6484
+ // Scoped (watcher) pushes only: reads the whole journal so the breaker's
6485
+ // denominator matches a full pass. Called only when the scoped ratio trips.
6486
+ bulkBaselineJournal) {
6392
6487
  const plan = { toDelete: [], toTombstone: [], refusedStale: [] };
6393
6488
  if (scopeRoots.length === 0)
6394
6489
  return plan;
@@ -6517,6 +6612,15 @@ companyScoped = true, skipSecretPaths = false, excludedPaths = new Set()) {
6517
6612
  continue;
6518
6613
  }
6519
6614
  }
6615
+ if (holdRemoteDeletes && !presentLocally) {
6616
+ plan.refusedStale.push({
6617
+ key: relativeKey,
6618
+ journalEtag: entry.remoteEtag ?? "<unknown>",
6619
+ remoteEtag: "<not-checked>",
6620
+ reason: "state-rebuilt-deletes-held",
6621
+ });
6622
+ continue;
6623
+ }
6520
6624
  const materializedKnowledgeDir = presentLocally &&
6521
6625
  isMaterializedKnowledgeDirectory(localPath, relativeKey, entry, companyScoped);
6522
6626
  if (presentLocally && !materializedKnowledgeDir)
@@ -6770,11 +6874,30 @@ companyScoped = true, skipSecretPaths = false, excludedPaths = new Set()) {
6770
6874
  // Bulk-asymmetry circuit-breaker. See `BULK_ASYMMETRY_*` constants for
6771
6875
  // rationale + bypass paths. Skipped under policy:"all" (caller asserted
6772
6876
  // intent) and under `HQ_SYNC_DELETE_BULK_OVERRIDE` (operator rollback knob).
6773
- if (policy !== "all" &&
6877
+ const bulkRatioTrips = () => inScopeJournalEntries > 0 &&
6878
+ bulkCandidatePicks / inScopeJournalEntries >= BULK_ASYMMETRY_RATIO;
6879
+ const bulkGuardApplies = policy !== "all" &&
6774
6880
  !isBulkAsymmetryOverride() &&
6775
- bulkCandidatePicks >= BULK_ASYMMETRY_MIN_ABS &&
6776
- inScopeJournalEntries > 0 &&
6777
- bulkCandidatePicks / inScopeJournalEntries >= BULK_ASYMMETRY_RATIO) {
6881
+ bulkCandidatePicks >= BULK_ASYMMETRY_MIN_ABS;
6882
+ if (bulkGuardApplies && bulkRatioTrips() && bulkBaselineJournal) {
6883
+ // The rows a full pass would count, with the same hard filters as the
6884
+ // Stage-1 loop. Rows it could not lstat (broken links) are not removed,
6885
+ // so this can only exceed a full pass's denominator by those rows.
6886
+ let baseline = 0;
6887
+ for (const [key, entry] of journalRows(bulkBaselineJournal()).entries()) {
6888
+ if (excludedPaths.has(key))
6889
+ continue;
6890
+ if (isNeverSyncedVaultPath(key))
6891
+ continue;
6892
+ if (skipSecretPaths && isSecretBearingVaultPath(key))
6893
+ continue;
6894
+ if (isTombstone(entry) && entry.removedReason !== "local-delete")
6895
+ continue;
6896
+ baseline++;
6897
+ }
6898
+ inScopeJournalEntries = Math.max(inScopeJournalEntries, baseline);
6899
+ }
6900
+ if (bulkGuardApplies && bulkRatioTrips()) {
6778
6901
  // Move every staged candidate (both already-bucketed toDelete from
6779
6902
  // owned-only and queued headCandidates from currency-gated) into
6780
6903
  // refusedStale with reason "bulk-asymmetry", and re-label the intent-less