@indigoai-us/hq-cloud 6.18.87 → 6.18.89

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 (69) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +16 -0
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner.d.ts +5 -0
  5. package/dist/bin/sync-runner.d.ts.map +1 -1
  6. package/dist/bin/sync-runner.js.map +1 -1
  7. package/dist/cli/reconcile-skip-reason.d.ts +23 -0
  8. package/dist/cli/reconcile-skip-reason.d.ts.map +1 -0
  9. package/dist/cli/reconcile-skip-reason.js +60 -0
  10. package/dist/cli/reconcile-skip-reason.js.map +1 -0
  11. package/dist/cli/rescue-core.d.ts +16 -1
  12. package/dist/cli/rescue-core.d.ts.map +1 -1
  13. package/dist/cli/rescue-core.js +83 -27
  14. package/dist/cli/rescue-core.js.map +1 -1
  15. package/dist/cli/share.d.ts +31 -1
  16. package/dist/cli/share.d.ts.map +1 -1
  17. package/dist/cli/share.js +193 -28
  18. package/dist/cli/share.js.map +1 -1
  19. package/dist/cli/sync.d.ts +36 -2
  20. package/dist/cli/sync.d.ts.map +1 -1
  21. package/dist/cli/sync.js +135 -27
  22. package/dist/cli/sync.js.map +1 -1
  23. package/dist/local-link-warning.d.ts +48 -0
  24. package/dist/local-link-warning.d.ts.map +1 -0
  25. package/dist/local-link-warning.js +138 -0
  26. package/dist/local-link-warning.js.map +1 -0
  27. package/dist/object-io.d.ts +16 -1
  28. package/dist/object-io.d.ts.map +1 -1
  29. package/dist/object-io.js +25 -2
  30. package/dist/object-io.js.map +1 -1
  31. package/dist/outposts/cli-billing.d.ts +2 -0
  32. package/dist/outposts/cli-billing.d.ts.map +1 -1
  33. package/dist/outposts/cli-billing.js +13 -1
  34. package/dist/outposts/cli-billing.js.map +1 -1
  35. package/dist/outposts/client.d.ts +37 -6
  36. package/dist/outposts/client.d.ts.map +1 -1
  37. package/dist/outposts/client.js +100 -18
  38. package/dist/outposts/client.js.map +1 -1
  39. package/dist/outposts/command.d.ts +11 -5
  40. package/dist/outposts/command.d.ts.map +1 -1
  41. package/dist/outposts/command.js +945 -63
  42. package/dist/outposts/command.js.map +1 -1
  43. package/dist/outposts/company-outposts.d.ts +92 -0
  44. package/dist/outposts/company-outposts.d.ts.map +1 -0
  45. package/dist/outposts/company-outposts.js +194 -0
  46. package/dist/outposts/company-outposts.js.map +1 -0
  47. package/dist/outposts/company.d.ts +67 -9
  48. package/dist/outposts/company.d.ts.map +1 -1
  49. package/dist/outposts/company.js +211 -11
  50. package/dist/outposts/company.js.map +1 -1
  51. package/dist/outposts/errors.d.ts +7 -0
  52. package/dist/outposts/errors.d.ts.map +1 -1
  53. package/dist/outposts/errors.js +7 -0
  54. package/dist/outposts/errors.js.map +1 -1
  55. package/dist/outposts/host-deps.d.ts +2 -0
  56. package/dist/outposts/host-deps.d.ts.map +1 -1
  57. package/dist/outposts/host-deps.js +12 -0
  58. package/dist/outposts/host-deps.js.map +1 -1
  59. package/dist/s3.d.ts +7 -1
  60. package/dist/s3.d.ts.map +1 -1
  61. package/dist/s3.js +33 -12
  62. package/dist/s3.js.map +1 -1
  63. package/dist/sync-progress.d.ts.map +1 -1
  64. package/dist/sync-progress.js +3 -0
  65. package/dist/sync-progress.js.map +1 -1
  66. package/dist/vault-client.d.ts.map +1 -1
  67. package/dist/vault-client.js +5 -1
  68. package/dist/vault-client.js.map +1 -1
  69. package/package.json +1 -1
package/dist/cli/share.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * Broadcasts local file(s) to the company's S3 vault bucket.
5
5
  * Refuses to overwrite a newer remote version without prompting.
6
6
  */
7
+ import { localLinkWarning, rememberLinkWarning, brokenLinkSkipEvent, LinkWarningCollector, LOCAL_LINK_REMEDY } from "../local-link-warning.js";
7
8
  import * as fs from "fs";
8
9
  import * as os from "os";
9
10
  import * as path from "path";
@@ -29,6 +30,7 @@ import { claimNeverSyncedLocalNotice, formatSecretsNotSyncedLine, isNeverSyncedV
29
30
  import { getActiveFullPassDecodeWindow, getActiveFullPassUnitSize, } from "../sync/full-pass-caps.js";
30
31
  import { isGeneratedCoreMirrorKey, isPersonalVaultDeletionExcluded, isSessionLogDeletionKey, purgeExcludedPersonalVaultJournalEntries, } from "../personal-vault.js";
31
32
  import { pickWinner, resolveConflict, usesVersionAwareWinner } from "./conflict.js";
33
+ import { createHumanSkipLineLog, formatReconcileSkipLine, isUpToDateSkipReason, } from "./reconcile-skip-reason.js";
32
34
  import { fetchFileTombstones, } from "./tombstones.js";
33
35
  import { compileScopeMatcher, } from "../prefix-coalesce.js";
34
36
  import { hasRemoteChanged, isAccessDenied, resolveActiveCompany, resolveTransferConcurrency, resolveUploadByteBudget, readHostLoad, } from "../sync-core.js";
@@ -490,6 +492,32 @@ function computePushPlan(filesToShare, journal, skipUnchanged, onHeartbeat, onto
490
492
  const localHash = hashSymlinkTarget(entry.target);
491
493
  const journalEntry = journalRows(journal).get(relativePath);
492
494
  const portable = portablePushSymlinkTarget(absolutePath, relativePath, entry.target, journalEntry?.wireSymlinkTarget, symlinkContext);
495
+ // Skip-unchanged runs before the link check: a dangling link already
496
+ // synced under this exact target is settled state, not a new warning
497
+ // every pass. A refused target never reaches this gate, as before.
498
+ if (skipUnchanged && !portable.refused) {
499
+ const existing = skipFingerprint(journal, relativePath);
500
+ // `!localDiverges`: see the regular-file gate below — a divergent entry
501
+ // stores the current LOCAL hash, so hash equality here proves only that
502
+ // local has not moved since the pull-side hold, never that it matches remote.
503
+ if (existing && existing.hash === localHash && !existing.localDiverges) {
504
+ omittedSkips += 1;
505
+ continue;
506
+ }
507
+ }
508
+ const warning = localLinkWarning(symlinkContext?.syncRoot ?? path.dirname(absolutePath), absolutePath, relativePath);
509
+ // A journaled link whose target string changed is still an upload, even
510
+ // when the new target does not exist yet. Skipping that retarget drops
511
+ // the edit and warns on every later pass. A never-synced broken link, a
512
+ // loop, and an unreadable link stay skips.
513
+ const uploadMissingRetarget = warning?.reason === "missing_target" &&
514
+ !portable.refused &&
515
+ journalEntry?.hash !== undefined &&
516
+ journalEntry.hash !== localHash;
517
+ if (warning && !uploadMissingRetarget && (!portable.refused || warning.reason === "loop")) {
518
+ items.push({ action: "skip-local-link", absolutePath, relativePath, warning });
519
+ continue;
520
+ }
493
521
  if (portable.refused) {
494
522
  items.push({
495
523
  action: "refuse-unsafe-symlink",
@@ -500,16 +528,6 @@ function computePushPlan(filesToShare, journal, skipUnchanged, onHeartbeat, onto
500
528
  });
501
529
  continue;
502
530
  }
503
- if (skipUnchanged) {
504
- const existing = skipFingerprint(journal, relativePath);
505
- // `!localDiverges`: see the regular-file gate below — a divergent entry
506
- // stores the current LOCAL hash, so hash equality here proves only that
507
- // local has not moved since the pull-side hold, never that it matches remote.
508
- if (existing && existing.hash === localHash && !existing.localDiverges) {
509
- omittedSkips += 1;
510
- continue;
511
- }
512
- }
513
531
  items.push({
514
532
  action: "upload",
515
533
  kind: "symlink",
@@ -1087,6 +1105,14 @@ export async function share(options) {
1087
1105
  }
1088
1106
  async function shareOnce(options) {
1089
1107
  const run = await createPushRunContext(options);
1108
+ try {
1109
+ return await runShareOnce(options, run);
1110
+ }
1111
+ finally {
1112
+ run.humanSkipLines?.flush();
1113
+ }
1114
+ }
1115
+ async function runShareOnce(options, run) {
1090
1116
  const counters = createShareCounters();
1091
1117
  const filesRefusedStalePaths = [];
1092
1118
  const conflictPaths = [];
@@ -1149,7 +1175,9 @@ async function shareOnce(options) {
1149
1175
  // A reconcile cursor is an all-or-nothing boundary for this bounded apply
1150
1176
  // slice. Upload and delete errors are intentionally reported per-path so an
1151
1177
  // ordinary runner can continue, but none of those retryable outcomes may
1152
- // move the cursor beyond the failed mutation.
1178
+ // move the cursor beyond the failed mutation. Broken links are deliberate
1179
+ // skips: their current slice is settled without claiming a file transfer,
1180
+ // and a repaired link remains eligible in a later discovery epoch.
1153
1181
  if (!uploadResult.hadUploadFailure &&
1154
1182
  uploadResult.workerErrors.length === 0 &&
1155
1183
  !pathResults.some((result) => result.status === "refused")) {
@@ -1220,7 +1248,9 @@ async function createPushRunContext(options) {
1220
1248
  // load average and self-trip the busy step-down to 32.
1221
1249
  const hostLoadAtStart = readHostLoad();
1222
1250
  const propagateDeletePolicy = options.propagateDeletePolicy ?? "owned-only";
1223
- const baseEmit = options.onEvent ?? defaultConsoleLogger;
1251
+ const humanSkipLines = options.onEvent ? undefined : createHumanSkipLineLog();
1252
+ const baseEmit = options.onEvent
1253
+ ?? ((event) => defaultConsoleLogger(event, humanSkipLines));
1224
1254
  if (vaultConfig && entityContext) {
1225
1255
  throw new Error("share() requires exactly one of `vaultConfig` or `entityContext`, not both. " +
1226
1256
  "Pass `vaultConfig` to vend credentials internally, or `entityContext` to use pre-vended ones.");
@@ -1306,6 +1336,7 @@ async function createPushRunContext(options) {
1306
1336
  : ctx.slug,
1307
1337
  phase: "push",
1308
1338
  });
1339
+ const linkWarnings = new LinkWarningCollector();
1309
1340
  const emit = (event) => {
1310
1341
  baseEmit(event);
1311
1342
  recordProgress(event);
@@ -1429,6 +1460,8 @@ async function createPushRunContext(options) {
1429
1460
  personalConnectorSettingsSync,
1430
1461
  symlinkTargetRefusal,
1431
1462
  reportedUnsafeSymlinkPaths: new Set(),
1463
+ linkWarnings,
1464
+ unreadableLinks: 0,
1432
1465
  skillRegistrationFallbacks: 0,
1433
1466
  paths,
1434
1467
  message,
@@ -1440,6 +1473,7 @@ async function createPushRunContext(options) {
1440
1473
  propagateDeletes,
1441
1474
  propagateDeletePolicy,
1442
1475
  emit,
1476
+ humanSkipLines,
1443
1477
  companyRef,
1444
1478
  ctx,
1445
1479
  privateFolderMatcher,
@@ -1532,6 +1566,9 @@ function createShareCounters() {
1532
1566
  filesUploaded: 0,
1533
1567
  bytesUploaded: 0,
1534
1568
  filesSkipped: 0,
1569
+ filesSkippedUpToDate: 0,
1570
+ filesSkippedConflict: 0,
1571
+ filesSkippedBrokenLink: 0,
1535
1572
  filesDeleted: 0,
1536
1573
  filesTombstoned: 0,
1537
1574
  filesRefusedStale: 0,
@@ -2139,6 +2176,14 @@ async function buildSharePlans(run) {
2139
2176
  run.unreachablePaths.set(namedPath, reason);
2140
2177
  }
2141
2178
  },
2179
+ onUnreadableLink: (key) => {
2180
+ const warning = { path: key, reason: "unreadable_link", remedy: LOCAL_LINK_REMEDY };
2181
+ if (run.linkWarnings.has(warning.path))
2182
+ return;
2183
+ run.unreadableLinks++;
2184
+ if (rememberLinkWarning(run.linkWarnings, warning))
2185
+ run.emit(brokenLinkSkipEvent(warning));
2186
+ },
2142
2187
  onLinkedSubtree: (rel) => run.linkedSubtreeSet.add(rel),
2143
2188
  onHeartbeat: run.options.onHeartbeat,
2144
2189
  cooperativeCheckpoint: run.options.cooperativeCheckpoint,
@@ -2249,6 +2294,10 @@ async function buildSharePlans(run) {
2249
2294
  const deletePlan = run.propagateDeletes === true
2250
2295
  ? await computeDeletePlan(run.journal, run.syncRoot, deleteScopeRoots, run.shouldSync, run.propagateDeletePolicy, run.ctx, run.options.personalMode !== true, run.skipSecretPaths, run.quarantinedPaths)
2251
2296
  : { toDelete: [], toTombstone: [], refusedStale: [] };
2297
+ for (const warning of deletePlan.brokenLinkSkipped ?? []) {
2298
+ if (rememberLinkWarning(run.linkWarnings, warning))
2299
+ run.emit(brokenLinkSkipEvent(warning));
2300
+ }
2252
2301
  const decommissionPlan = !run.scopedJournal && (run.options.decommissionPrefixes ?? []).length > 0
2253
2302
  ? computeDecommissionPlan(run.journal, run.options.decommissionPrefixes ?? [], run.propagateDeletePolicy, new Set([...deletePlan.toDelete.map((item) => item.key), ...deletePlan.toTombstone]), run.skipSecretPaths)
2254
2303
  : [];
@@ -2456,6 +2505,8 @@ function debugPlanLimit(fields) {
2456
2505
  }
2457
2506
  async function executeUploads(run, pushPlan, counters, conflictPaths) {
2458
2507
  counters.filesSkipped += pushPlan.preplannedFilesSkipped;
2508
+ counters.filesSkippedUpToDate += pushPlan.preplannedFilesSkipped;
2509
+ run.shareCounters = counters;
2459
2510
  const TRANSFER_CONCURRENCY = resolveTransferConcurrency(run.hostLoadAtStart, run.options.transferPriority ?? "chore");
2460
2511
  let conflictPromptChain = Promise.resolve();
2461
2512
  const resolveConflictSerialized = (info) => {
@@ -2645,12 +2696,27 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
2645
2696
  emitServerOwnedRefusal(run, item.relativePath);
2646
2697
  }
2647
2698
  }
2699
+ else if (item.action === "skip-local-link") {
2700
+ counters.filesSkippedBrokenLink++;
2701
+ if (rememberLinkWarning(run.linkWarnings, item.warning)) {
2702
+ run.emit(brokenLinkSkipEvent(item.warning));
2703
+ }
2704
+ }
2648
2705
  else if (item.action === "refuse-unsafe-symlink") {
2706
+ // One event per refused link: skip-unsafe-symlink-target carries the
2707
+ // remedy, so a broken outside-root link is recorded in linkWarnings but
2708
+ // never also emitted as skip-broken-link (which double-counted progress).
2709
+ const warning = localLinkWarning(run.syncRoot, item.absolutePath, item.relativePath);
2710
+ if (warning) {
2711
+ counters.filesSkippedBrokenLink++;
2712
+ rememberLinkWarning(run.linkWarnings, warning);
2713
+ }
2649
2714
  run.emit({
2650
2715
  type: "skip-unsafe-symlink-target",
2651
2716
  path: item.relativePath,
2652
2717
  classification: item.classification,
2653
2718
  shape: item.shape,
2719
+ ...(warning ? { remedy: warning.remedy } : {}),
2654
2720
  });
2655
2721
  }
2656
2722
  else if (item.action === "skip-size-limit") {
@@ -2678,6 +2744,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
2678
2744
  size: item.restamp.size,
2679
2745
  });
2680
2746
  }
2747
+ emitPushSkip(run, item.relativePath, "already_up_to_date");
2681
2748
  }
2682
2749
  counters.filesSkipped++;
2683
2750
  };
@@ -2710,6 +2777,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
2710
2777
  });
2711
2778
  if (refreshed === null) {
2712
2779
  counters.filesSkipped++;
2780
+ emitPushSkip(run, relativePath, "vanished_before_upload");
2713
2781
  return null;
2714
2782
  }
2715
2783
  if (refreshed.action !== "upload") {
@@ -2726,6 +2794,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
2726
2794
  // cannot monopolise a worker; the next sync pass will try it again.
2727
2795
  if (replanAttempts >= 3) {
2728
2796
  counters.filesSkipped++;
2797
+ emitPushSkip(run, relativePath, "retry_budget_exhausted");
2729
2798
  return;
2730
2799
  }
2731
2800
  await processUploadItem(refreshed, replanAttempts + 1, samePathHold);
@@ -2876,8 +2945,11 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
2876
2945
  !entityIsFreshForCreate(fs.readFileSync(absolutePath, "utf8"), Date.now());
2877
2946
  if ((cloudWinsEntity && !remoteMeta && journalEntry) || staleEntityCreate) {
2878
2947
  counters.filesSkipped++;
2879
- if (journalEntry && journalEntry.hash === localHash)
2948
+ if (journalEntry && journalEntry.hash === localHash) {
2949
+ emitPushSkip(run, relativePath, "vault_deleted_local_unchanged");
2880
2950
  return;
2951
+ }
2952
+ counters.filesSkippedConflict++;
2881
2953
  conflictPaths.push(relativePath);
2882
2954
  if (!isMaterializationPathStillContained(run.syncRoot, item.absolutePath)) {
2883
2955
  run.emit({ type: "conflict", path: relativePath, direction: "push", resolution: "skip" });
@@ -2910,6 +2982,8 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
2910
2982
  // explicit `overwrite` — consent given with the deletion in view — falls
2911
2983
  // through to the create below.
2912
2984
  counters.filesSkipped++;
2985
+ counters.filesSkippedConflict++;
2986
+ emitPushSkip(run, relativePath, "remote_deleted_not_recreated");
2913
2987
  return;
2914
2988
  }
2915
2989
  }
@@ -2932,7 +3006,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
2932
3006
  preserveSyncedAt: true,
2933
3007
  });
2934
3008
  checkpointShareJournal(run);
2935
- run.emit({ type: "reconciled", path: relativePath, direction: "push" });
3009
+ emitPushSkip(run, relativePath, "already_up_to_date");
2936
3010
  counters.filesSkipped++;
2937
3011
  return;
2938
3012
  }
@@ -2949,6 +3023,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
2949
3023
  conflictPaths.push(relativePath);
2950
3024
  run.emit({ type: "conflict", path: relativePath, direction: "push", resolution: "skip" });
2951
3025
  counters.filesSkipped++;
3026
+ counters.filesSkippedConflict++;
2952
3027
  return;
2953
3028
  }
2954
3029
  // Every path below that needs the remote BODY — the no-row equality
@@ -2983,6 +3058,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
2983
3058
  wireSymlinkTarget: remoteTarget,
2984
3059
  });
2985
3060
  flushShareJournal(run);
3061
+ emitPushSkip(run, relativePath, "already_up_to_date");
2986
3062
  counters.filesSkipped++;
2987
3063
  return;
2988
3064
  }
@@ -3058,7 +3134,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3058
3134
  settleLandedReconcile(run, relativePath, outcome);
3059
3135
  }
3060
3136
  checkpointShareJournal(run);
3061
- run.emit({ type: "reconciled", path: relativePath, direction: "push" });
3137
+ emitPushSkip(run, relativePath, "already_landed");
3062
3138
  counters.filesSkipped++;
3063
3139
  return;
3064
3140
  }
@@ -3086,6 +3162,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3086
3162
  // upload drops nothing. Leave the journal alone; the next pass retries.
3087
3163
  boardProbe.discard();
3088
3164
  counters.filesSkipped++;
3165
+ emitPushSkip(run, relativePath, "remote_body_unreadable");
3089
3166
  return;
3090
3167
  }
3091
3168
  const outcome = mergeAppendOnlyPushConflict(run, item, boardProbe, normalizeEtag(remoteMeta.etag), mergeBoardJson, replaceIfUnchanged);
@@ -3094,8 +3171,10 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3094
3171
  fenceOnObservedRemote = true;
3095
3172
  }
3096
3173
  else if (outcome !== "not-mergeable") {
3097
- if (outcome === "write-failed")
3174
+ if (outcome === "write-failed") {
3175
+ // The merge already emitted the error. A second skip line repeats it.
3098
3176
  hadUploadFailure = true;
3177
+ }
3099
3178
  counters.filesSkipped++;
3100
3179
  return;
3101
3180
  }
@@ -3111,6 +3190,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3111
3190
  run.emit({ type: "conflict", path: relativePath, direction: "push", resolution: "keep" });
3112
3191
  await adoptRemoteOnPush(run, item, normalizeEtag(remoteMeta.etag), undefined);
3113
3192
  counters.filesSkipped++;
3193
+ counters.filesSkippedConflict++;
3114
3194
  return;
3115
3195
  }
3116
3196
  // Version-aware winner (strategy unset or `keep`): fetch the remote
@@ -3149,8 +3229,10 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3149
3229
  if (conflictIndex >= 0)
3150
3230
  conflictPaths.splice(conflictIndex, 1);
3151
3231
  if (outcome !== "publish-local") {
3152
- if (outcome === "write-failed")
3232
+ if (outcome === "write-failed") {
3233
+ // The merge already emitted the error. A second skip line repeats it.
3153
3234
  hadUploadFailure = true;
3235
+ }
3154
3236
  counters.filesSkipped++;
3155
3237
  return;
3156
3238
  }
@@ -3192,6 +3274,8 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3192
3274
  // so the conflict is retried next pass.
3193
3275
  winnerProbe?.discard();
3194
3276
  counters.filesSkipped++;
3277
+ counters.filesSkippedConflict++;
3278
+ emitPushSkip(run, relativePath, "held_for_next_pass");
3195
3279
  return;
3196
3280
  }
3197
3281
  recordPushConflict(run, item, {
@@ -3228,12 +3312,14 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3228
3312
  // tree, never as a sibling twin.
3229
3313
  await adoptRemoteOnPush(run, item, normalizeEtag(remoteMeta.etag), decision, winnerProbe);
3230
3314
  counters.filesSkipped++;
3315
+ counters.filesSkippedConflict++;
3231
3316
  return;
3232
3317
  }
3233
3318
  }
3234
3319
  if (resolution === "skip") {
3235
3320
  // Leave both sides alone; no sibling mirror is written any more.
3236
3321
  counters.filesSkipped++;
3322
+ counters.filesSkippedConflict++;
3237
3323
  return;
3238
3324
  }
3239
3325
  // `publish-local` on a FRESH two-sided conflict must fence on the etag
@@ -3458,6 +3544,8 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3458
3544
  : undefined;
3459
3545
  if (builtOnThisPut) {
3460
3546
  adoptLandedSamePathUpload(run, item, builtOnThisPut);
3547
+ if (alreadyCurrent)
3548
+ emitPushSkip(run, relativePath, "already_up_to_date");
3461
3549
  return;
3462
3550
  }
3463
3551
  // A row on disk at an etag this process has since replaced came from
@@ -3471,6 +3559,8 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3471
3559
  journalRows(run.journal).set(relativePath, latestEntry);
3472
3560
  else
3473
3561
  journalRows(run.journal).delete(relativePath);
3562
+ if (alreadyCurrent)
3563
+ emitPushSkip(run, relativePath, "already_up_to_date");
3474
3564
  return;
3475
3565
  }
3476
3566
  }
@@ -3514,7 +3604,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3514
3604
  bytes: size,
3515
3605
  });
3516
3606
  if (alreadyCurrent) {
3517
- run.emit({ type: "reconciled", path: relativePath, direction: "push" });
3607
+ emitPushSkip(run, relativePath, "already_up_to_date");
3518
3608
  }
3519
3609
  else {
3520
3610
  run.emit({
@@ -3565,6 +3655,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3565
3655
  await adoptRemoteOnPush(run, item, normalizeEtag(latest.etag), undefined);
3566
3656
  }
3567
3657
  counters.filesSkipped++;
3658
+ counters.filesSkippedConflict++;
3568
3659
  return;
3569
3660
  }
3570
3661
  if (usesVersionAwareWinner(run.onConflict)) {
@@ -3601,7 +3692,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3601
3692
  preserveSyncedAt: true,
3602
3693
  });
3603
3694
  checkpointShareJournal(run);
3604
- run.emit({ type: "reconciled", path: relativePath, direction: "push" });
3695
+ emitPushSkip(run, relativePath, "already_up_to_date");
3605
3696
  counters.filesSkipped++;
3606
3697
  return;
3607
3698
  }
@@ -3616,8 +3707,14 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3616
3707
  const conflictIndex = conflictPaths.lastIndexOf(relativePath);
3617
3708
  if (conflictIndex >= 0)
3618
3709
  conflictPaths.splice(conflictIndex, 1);
3619
- if (outcome === "write-failed")
3710
+ if (outcome === "write-failed") {
3711
+ // The merge already emitted the error. A second skip line repeats it.
3620
3712
  hadUploadFailure = true;
3713
+ }
3714
+ else if (outcome === "publish-local") {
3715
+ counters.filesSkippedConflict++;
3716
+ emitPushSkip(run, relativePath, "held_for_next_pass");
3717
+ }
3621
3718
  counters.filesSkipped++;
3622
3719
  return;
3623
3720
  }
@@ -3638,6 +3735,8 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3638
3735
  // newer remote until its body has been safely parked.
3639
3736
  probe.discard();
3640
3737
  counters.filesSkipped++;
3738
+ counters.filesSkippedConflict++;
3739
+ emitPushSkip(run, relativePath, "held_for_next_pass");
3641
3740
  return;
3642
3741
  }
3643
3742
  recordPushConflict(run, item, {
@@ -3684,6 +3783,8 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3684
3783
  return;
3685
3784
  if (isPreconditionFailed(retryErr)) {
3686
3785
  counters.filesSkipped++;
3786
+ counters.filesSkippedConflict++;
3787
+ emitPushSkip(run, relativePath, "held_for_next_pass");
3687
3788
  return;
3688
3789
  }
3689
3790
  hadUploadFailure = true;
@@ -3709,6 +3810,8 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3709
3810
  // both copies and the journal unchanged until the next pass.
3710
3811
  probe.discard();
3711
3812
  counters.filesSkipped++;
3813
+ counters.filesSkippedConflict++;
3814
+ emitPushSkip(run, relativePath, "held_for_next_pass");
3712
3815
  return;
3713
3816
  }
3714
3817
  recordPushConflict(run, item, {
@@ -3724,6 +3827,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3724
3827
  // Deliberately NOT `type: "error"` (a hold is a benign per-file
3725
3828
  // skip; the conflict event + index row above already record it).
3726
3829
  counters.filesSkipped++;
3830
+ counters.filesSkippedConflict++;
3727
3831
  return;
3728
3832
  }
3729
3833
  await adoptRemoteOnPush(run, item, latest?.etag
@@ -3765,6 +3869,8 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3765
3869
  // non-interactive strategy returns — stays fenced.
3766
3870
  if (resolution === "publish-local") {
3767
3871
  counters.filesSkipped++;
3872
+ counters.filesSkippedConflict++;
3873
+ emitPushSkip(run, relativePath, "body_changed_during_decision");
3768
3874
  return;
3769
3875
  }
3770
3876
  if (resolution === "overwrite") {
@@ -3799,6 +3905,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3799
3905
  }
3800
3906
  // `skip` and `diff`: leave both sides for the next pass.
3801
3907
  counters.filesSkipped++;
3908
+ counters.filesSkippedConflict++;
3802
3909
  return;
3803
3910
  }
3804
3911
  if (isAccessDenied(err)) {
@@ -3908,6 +4015,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
3908
4015
  if (item === null) {
3909
4016
  queue.shift();
3910
4017
  counters.filesSkipped++;
4018
+ emitPushSkip(run, previewItem.relativePath, "vanished_before_upload");
3911
4019
  skippedSinceCooperativeCheckpoint++;
3912
4020
  continue;
3913
4021
  }
@@ -4405,9 +4513,11 @@ function mergeAppendOnlyPushConflict(run, item, probe, remoteEtag, merge = merge
4405
4513
  });
4406
4514
  return "write-failed";
4407
4515
  }
4408
- if (appended === "changed")
4516
+ if (appended === "changed") {
4517
+ emitPushSkip(run, item.relativePath, "body_changed_during_decision");
4409
4518
  return "local-changed";
4410
- run.emit({ type: "reconciled", path: item.relativePath, direction: "push" });
4519
+ }
4520
+ emitPushSkip(run, item.relativePath, merged.equals(remoteBody) ? "already_up_to_date" : "merged_with_remote");
4411
4521
  if (!merged.equals(remoteBody))
4412
4522
  return "merged-locally";
4413
4523
  const lstat = fs.lstatSync(item.absolutePath);
@@ -4493,6 +4603,7 @@ async function adoptRemoteOnPush(run, item, remoteHash, decision, probe) {
4493
4603
  symlinkTargetLinkPath: run.options.personalMode === true
4494
4604
  ? personalSymlinkTargetLinkPath(item.relativePath)
4495
4605
  : `companies/${run.ctx.slug}/${item.relativePath}`,
4606
+ syncRoot: run.syncRoot,
4496
4607
  });
4497
4608
  }
4498
4609
  remoteAdopted = true;
@@ -5190,7 +5301,10 @@ function buildShareResult(run, counters, filesRefusedStalePaths, conflictPaths,
5190
5301
  return {
5191
5302
  filesUploaded: counters.filesUploaded,
5192
5303
  bytesUploaded: counters.bytesUploaded,
5193
- filesSkipped: counters.filesSkipped,
5304
+ linkWarnings: run.linkWarnings.warnings,
5305
+ linkWarningsOmitted: run.linkWarnings.omitted,
5306
+ filesSkipped: counters.filesSkipped + run.unreadableLinks,
5307
+ filesSkippedDetail: skipDetail(counters, run.unreadableLinks),
5194
5308
  filesDeleted: counters.filesDeleted,
5195
5309
  filesTombstoned: counters.filesTombstoned,
5196
5310
  filesRefusedStale: counters.filesRefusedStale,
@@ -5213,11 +5327,24 @@ function buildShareResult(run, counters, filesRefusedStalePaths, conflictPaths,
5213
5327
  aborted,
5214
5328
  };
5215
5329
  }
5330
+ function skipDetail(counters, unreadableLinks) {
5331
+ const upToDate = counters.filesSkippedUpToDate;
5332
+ const conflict = counters.filesSkippedConflict;
5333
+ const brokenLink = counters.filesSkippedBrokenLink + unreadableLinks;
5334
+ const other = Math.max(0, counters.filesSkipped + unreadableLinks - upToDate - conflict - brokenLink);
5335
+ return { upToDate, conflict, brokenLink, other };
5336
+ }
5337
+ function emitPushSkip(run, filePath, reason) {
5338
+ run.emit({ type: "reconciled", path: filePath, direction: "push", reason });
5339
+ if (run.shareCounters && isUpToDateSkipReason(reason)) {
5340
+ run.shareCounters.filesSkippedUpToDate++;
5341
+ }
5342
+ }
5216
5343
  /**
5217
5344
  * Default human-readable share output. Preserves the exact format the CLI
5218
5345
  * emitted before `onEvent` was added — tty users see no change.
5219
5346
  */
5220
- function defaultConsoleLogger(event) {
5347
+ function defaultConsoleLogger(event, humanSkipLines) {
5221
5348
  if (event.type === "plan") {
5222
5349
  if (event.filesToUpload > 0) {
5223
5350
  console.log(`Plan: ${event.filesToUpload} to upload (${event.bytesToUpload} bytes), ${event.filesToSkip} unchanged`);
@@ -5245,6 +5372,12 @@ function defaultConsoleLogger(event) {
5245
5372
  else if (event.type === "conflict") {
5246
5373
  console.error(` ⚠ conflict (${event.direction}): ${event.path} — ${event.resolution}`);
5247
5374
  }
5375
+ else if (event.type === "reconciled") {
5376
+ if (humanSkipLines)
5377
+ humanSkipLines.note(event.path, event.reason, event.direction);
5378
+ else
5379
+ console.log(formatReconcileSkipLine(event.path, event.reason, event.direction));
5380
+ }
5248
5381
  else if (event.type === "push-refused-server-owned") {
5249
5382
  const nextStep = event.nextStep === "use-source-ingestion"
5250
5383
  ? "Add the source through its supported integration or ingestion flow, then run hq sync pull."
@@ -5259,6 +5392,9 @@ function defaultConsoleLogger(event) {
5259
5392
  console.warn(` ... and ${event.count - event.samplePaths.length} more`);
5260
5393
  }
5261
5394
  }
5395
+ else if (event.type === "skip-broken-link") {
5396
+ console.warn(` ! ${event.path} skipped — ${event.message}`);
5397
+ }
5262
5398
  else if (event.type === "error") {
5263
5399
  console.error(` ✗ ${event.path} — ${event.message}`);
5264
5400
  }
@@ -5728,6 +5864,8 @@ async function collectFiles(paths, hqRoot, syncRoot, filter, hooks = {}) {
5728
5864
  const target = readlinkOrNull(absolutePath);
5729
5865
  if (target === null) {
5730
5866
  console.error(` Warning: ${p} is an unreadable symbolic link, skipping.`);
5867
+ if (hooks.onUnreadableLink)
5868
+ hooks.onUnreadableLink(relativePath);
5731
5869
  hooks.onUnreachablePath?.(p, "unreadable-link");
5732
5870
  continue;
5733
5871
  }
@@ -5829,6 +5967,8 @@ async function walkDir(dir, syncRoot, filter, results, hooks = {}, cooperativeWo
5829
5967
  const target = readlinkOrNull(absolutePath);
5830
5968
  if (target === null) {
5831
5969
  console.error(` Warning: ${linkRelative} is an unreadable symbolic link, skipping.`);
5970
+ if (hooks.onUnreadableLink)
5971
+ hooks.onUnreadableLink(linkRelative);
5832
5972
  hooks.onUnreachablePath?.(linkRelative, "unreadable-link");
5833
5973
  continue;
5834
5974
  }
@@ -6253,6 +6393,8 @@ companyScoped = true, skipSecretPaths = false, excludedPaths = new Set()) {
6253
6393
  // of roots; test each row by walking its ancestors, never rows x roots.
6254
6394
  const scopeRootSet = new Set(scopeRoots);
6255
6395
  const everyRowInScope = scopeRootSet.has("");
6396
+ const linkInspectCache = new Map();
6397
+ const brokenLinkPaths = new Set();
6256
6398
  for (const [relativeKey, entry] of journalRows(journal).entries()) {
6257
6399
  if (excludedPaths.has(relativeKey))
6258
6400
  continue;
@@ -6302,20 +6444,43 @@ companyScoped = true, skipSecretPaths = false, excludedPaths = new Set()) {
6302
6444
  inScopeJournalEntries++;
6303
6445
  const localPath = localPathForVaultKey(syncRoot, relativeKey);
6304
6446
  let presentLocally = true;
6447
+ let lstatError;
6305
6448
  try {
6306
6449
  fs.lstatSync(localPath);
6307
6450
  }
6308
6451
  catch (err) {
6309
- if (err &&
6310
- typeof err === "object" &&
6311
- "code" in err &&
6312
- err.code === "ENOENT") {
6452
+ const code = err && typeof err === "object" && "code" in err
6453
+ ? err.code
6454
+ : undefined;
6455
+ // ELOOP and ENOTDIR come from a directory link above the key (a loop,
6456
+ // or a link that no longer resolves to a directory). They are settled
6457
+ // below only when that link is broken; otherwise they still throw.
6458
+ if (code === "ENOENT" || code === "ELOOP" || code === "ENOTDIR") {
6313
6459
  presentLocally = false;
6460
+ if (code !== "ENOENT")
6461
+ lstatError = err;
6314
6462
  }
6315
6463
  else {
6316
6464
  throw err;
6317
6465
  }
6318
6466
  }
6467
+ // A missing child under a broken directory link (dangling or looping)
6468
+ // means the link hides the file, not that the user deleted it. Record one
6469
+ // warning per link and keep the key out of both sides of the
6470
+ // bulk-asymmetry ratio, like a tombstone.
6471
+ if (!presentLocally) {
6472
+ const linkWarning = localLinkWarning(syncRoot, path.dirname(localPath), path.posix.dirname(relativeKey), linkInspectCache);
6473
+ if (!linkWarning && lstatError !== undefined)
6474
+ throw lstatError;
6475
+ if (linkWarning) {
6476
+ inScopeJournalEntries--;
6477
+ if (!brokenLinkPaths.has(linkWarning.path)) {
6478
+ brokenLinkPaths.add(linkWarning.path);
6479
+ (plan.brokenLinkSkipped ??= []).push(linkWarning);
6480
+ }
6481
+ continue;
6482
+ }
6483
+ }
6319
6484
  const materializedKnowledgeDir = presentLocally &&
6320
6485
  isMaterializedKnowledgeDirectory(localPath, relativeKey, entry, companyScoped);
6321
6486
  if (presentLocally && !materializedKnowledgeDir)