@indigoai-us/hq-cloud 6.18.53 → 6.18.56-alpha.0

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 (106) hide show
  1. package/dist/bin/sync-runner-audit.d.ts +26 -0
  2. package/dist/bin/sync-runner-audit.d.ts.map +1 -0
  3. package/dist/bin/sync-runner-audit.js +51 -0
  4. package/dist/bin/sync-runner-audit.js.map +1 -0
  5. package/dist/bin/sync-runner-company.d.ts +18 -0
  6. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  7. package/dist/bin/sync-runner-company.js +186 -9
  8. package/dist/bin/sync-runner-company.js.map +1 -1
  9. package/dist/bin/sync-runner-watch-loop.d.ts +107 -5
  10. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-watch-loop.js +1382 -183
  12. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  13. package/dist/bin/sync-runner.d.ts +63 -11
  14. package/dist/bin/sync-runner.d.ts.map +1 -1
  15. package/dist/bin/sync-runner.js +350 -95
  16. package/dist/bin/sync-runner.js.map +1 -1
  17. package/dist/bin/watcher-catchup-skip-store.d.ts +60 -0
  18. package/dist/bin/watcher-catchup-skip-store.d.ts.map +1 -0
  19. package/dist/bin/watcher-catchup-skip-store.js +282 -0
  20. package/dist/bin/watcher-catchup-skip-store.js.map +1 -0
  21. package/dist/cli/conflict.d.ts +6 -0
  22. package/dist/cli/conflict.d.ts.map +1 -1
  23. package/dist/cli/conflict.js +3 -1
  24. package/dist/cli/conflict.js.map +1 -1
  25. package/dist/cli/reindex.d.ts.map +1 -1
  26. package/dist/cli/reindex.js +95 -5
  27. package/dist/cli/reindex.js.map +1 -1
  28. package/dist/cli/share.d.ts +27 -0
  29. package/dist/cli/share.d.ts.map +1 -1
  30. package/dist/cli/share.js +140 -23
  31. package/dist/cli/share.js.map +1 -1
  32. package/dist/cli/sync.d.ts +18 -2
  33. package/dist/cli/sync.d.ts.map +1 -1
  34. package/dist/cli/sync.js +90 -25
  35. package/dist/cli/sync.js.map +1 -1
  36. package/dist/ignore.d.ts +1 -1
  37. package/dist/ignore.d.ts.map +1 -1
  38. package/dist/ignore.js +2 -2
  39. package/dist/ignore.js.map +1 -1
  40. package/dist/journal-row-store.d.ts.map +1 -1
  41. package/dist/journal-row-store.js +6 -0
  42. package/dist/journal-row-store.js.map +1 -1
  43. package/dist/journal-snapshot-v4.d.ts +8 -0
  44. package/dist/journal-snapshot-v4.d.ts.map +1 -1
  45. package/dist/journal-snapshot-v4.js +208 -17
  46. package/dist/journal-snapshot-v4.js.map +1 -1
  47. package/dist/journal.d.ts +18 -2
  48. package/dist/journal.d.ts.map +1 -1
  49. package/dist/journal.js +248 -17
  50. package/dist/journal.js.map +1 -1
  51. package/dist/lib/cloud-authoritative.d.ts +9 -2
  52. package/dist/lib/cloud-authoritative.d.ts.map +1 -1
  53. package/dist/lib/cloud-authoritative.js +34 -2
  54. package/dist/lib/cloud-authoritative.js.map +1 -1
  55. package/dist/lib/exit-codes.d.ts +2 -0
  56. package/dist/lib/exit-codes.d.ts.map +1 -1
  57. package/dist/lib/exit-codes.js +2 -0
  58. package/dist/lib/exit-codes.js.map +1 -1
  59. package/dist/personal-vault-exclusions.d.ts +2 -2
  60. package/dist/personal-vault-exclusions.d.ts.map +1 -1
  61. package/dist/personal-vault-exclusions.js +5 -3
  62. package/dist/personal-vault-exclusions.js.map +1 -1
  63. package/dist/personal-vault.d.ts +7 -0
  64. package/dist/personal-vault.d.ts.map +1 -1
  65. package/dist/personal-vault.js +10 -0
  66. package/dist/personal-vault.js.map +1 -1
  67. package/dist/qmd-runner-admission.d.ts +22 -0
  68. package/dist/qmd-runner-admission.d.ts.map +1 -0
  69. package/dist/qmd-runner-admission.js +107 -0
  70. package/dist/qmd-runner-admission.js.map +1 -0
  71. package/dist/symlink-target-policy.d.ts +11 -0
  72. package/dist/symlink-target-policy.d.ts.map +1 -1
  73. package/dist/symlink-target-policy.js +110 -0
  74. package/dist/symlink-target-policy.js.map +1 -1
  75. package/dist/sync/area-ledger-migration.d.ts +3 -1
  76. package/dist/sync/area-ledger-migration.d.ts.map +1 -1
  77. package/dist/sync/area-ledger-migration.js +3 -0
  78. package/dist/sync/area-ledger-migration.js.map +1 -1
  79. package/dist/sync/area-ledger.d.ts +3 -0
  80. package/dist/sync/area-ledger.d.ts.map +1 -1
  81. package/dist/sync/area-ledger.js +29 -6
  82. package/dist/sync/area-ledger.js.map +1 -1
  83. package/dist/sync/mutation-session.d.ts.map +1 -1
  84. package/dist/sync/mutation-session.js +4 -0
  85. package/dist/sync/mutation-session.js.map +1 -1
  86. package/dist/sync/parallel-sync-coordinator.d.ts +29 -0
  87. package/dist/sync/parallel-sync-coordinator.d.ts.map +1 -1
  88. package/dist/sync/parallel-sync-coordinator.js +229 -16
  89. package/dist/sync/parallel-sync-coordinator.js.map +1 -1
  90. package/dist/sync/push-receiver.d.ts +5 -0
  91. package/dist/sync/push-receiver.d.ts.map +1 -1
  92. package/dist/sync/push-receiver.js +32 -10
  93. package/dist/sync/push-receiver.js.map +1 -1
  94. package/dist/sync/state-store.d.ts +7 -0
  95. package/dist/sync/state-store.d.ts.map +1 -1
  96. package/dist/sync/state-store.js +113 -6
  97. package/dist/sync/state-store.js.map +1 -1
  98. package/dist/watch-owner-lease.d.ts +4 -0
  99. package/dist/watch-owner-lease.d.ts.map +1 -1
  100. package/dist/watch-owner-lease.js +40 -6
  101. package/dist/watch-owner-lease.js.map +1 -1
  102. package/dist/watcher.d.ts +16 -3
  103. package/dist/watcher.d.ts.map +1 -1
  104. package/dist/watcher.js +114 -21
  105. package/dist/watcher.js.map +1 -1
  106. package/package.json +4 -3
@@ -21,6 +21,7 @@
21
21
  * fanout. Combined with HQ_SYNC_SKIP_PERSONAL env
22
22
  * (either truthy disables personal sync). No-op
23
23
  * outside --companies mode.
24
+ * --qmd-full-refresh Bypass the post-sync QMD minimum interval.
24
25
  * --json Ignored — ndjson on stdout is the default and
25
26
  * only output mode. Accepted for symmetry with the
26
27
  * AppBar's argv in case someone passes it.
@@ -68,6 +69,7 @@
68
69
  import * as os from "os";
69
70
  import * as path from "path";
70
71
  import * as fs from "fs";
72
+ import { localPathForVaultKey } from "../local-path-codec.js";
71
73
  import { randomUUID } from "node:crypto";
72
74
  import { monitorEventLoopDelay, performance } from "node:perf_hooks";
73
75
  import { fileURLToPath } from "url";
@@ -79,6 +81,7 @@ import { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL, computePersonalVaultPaths, } from ".
79
81
  import { sync as defaultSync } from "../cli/sync.js";
80
82
  import { releaseHeldOperationLocks, suppressOperationLockSignalReraise, } from "../operation-lock.js";
81
83
  import { share as defaultShare } from "../cli/share.js";
84
+ import { WatcherCatchupSkipStore, watcherCatchupSkipStorePath, } from "./watcher-catchup-skip-store.js";
82
85
  import { ensureCompanyVaultTransport } from "../company-vault-transport.js";
83
86
  import { HQ_CLOUD_VERSION } from "../version.js";
84
87
  import { collectAndSendTelemetry } from "../telemetry.js";
@@ -87,6 +90,7 @@ import { collectAndSendOutcomeTelemetry, OUTCOME_STORY_DONE_STATE_FLAG, } from "
87
90
  import { resolveOntologyEntityPush } from "../lib/ontology-entity-push.js";
88
91
  import { emitCloudTelemetry } from "../telemetry-events.js";
89
92
  import { reindexAfterSync } from "../qmd-reindex.js";
93
+ import { acquireQmdBackgroundLock, isQmdBackgroundLockHeld, qmdRunnerLastFinishedAt, qmdRunnerShouldSkip, recordQmdRunnerFinished, } from "../qmd-runner-admission.js";
90
94
  import { reindexAsync as defaultReindexAsync } from "../cli/reindex-async.js";
91
95
  /**
92
96
  * The runner reindexes after nearly every pass, and each reindex rewrites every
@@ -101,11 +105,12 @@ import { createPostSyncFlagEvaluator, POST_SYNC_FLAG_KEYS, } from "./sync-runner
101
105
  import { runPostSyncManifestUploads, } from "./sync-runner-manifest.js";
102
106
  import { getOrCreateMachineId } from "../lib/machine-id.js";
103
107
  import { isTransientNetworkError, TRANSIENT_NETWORK_EXIT, } from "../lib/net-errors.js";
104
- import { PARTIAL_SYNC_EXIT, UNRESOLVED_COMPANY_EXIT, WATCH_RUNNER_ALREADY_OWNED_EXIT, WATCH_OWNER_LEASE_LOST_EXIT, } from "../lib/exit-codes.js";
108
+ import { PARTIAL_SYNC_EXIT, UNRESOLVED_COMPANY_EXIT, PREFLIGHT_HALT_EXIT, WATCH_RUNNER_ALREADY_OWNED_EXIT, WATCH_OWNER_LEASE_LOST_EXIT, } from "../lib/exit-codes.js";
109
+ import { SyncRunnerHashAudit } from "./sync-runner-audit.js";
105
110
  import { acquireWatchOwnerLease } from "../watch-owner-lease.js";
106
111
  import { startWatchParentExitMonitor } from "../watch-parent.js";
107
112
  import { describeError } from "../lib/describe-error.js";
108
- import { migratePersonalVaultJournal, PERSONAL_VAULT_JOURNAL_SLUG, getStateDir, repairJournalStateIfNeeded as defaultRepairJournalStateIfNeeded, } from "../journal.js";
113
+ import { setAsyncJournalCompactionForWatchRunner, migratePersonalVaultJournal, PERSONAL_VAULT_JOURNAL_SLUG, getStateDir, repairJournalStateIfNeeded as defaultRepairJournalStateIfNeeded, } from "../journal.js";
109
114
  import { StateStoreLockError } from "../sync/state-store.js";
110
115
  import { resolveParallelSyncRollout } from "../sync/feature-flags.js";
111
116
  import { createRunnerEmitter, emitUncaughtRunnerError, notifyRunnerEmitObserver, } from "./sync-runner-events.js";
@@ -460,6 +465,9 @@ export function parseArgs(argv) {
460
465
  let scopeContentHashes;
461
466
  let eventPush = false;
462
467
  let lockTimeoutSec;
468
+ let qmdFullRefresh = false;
469
+ let auditHashOut;
470
+ const preflightRaw = [];
463
471
  for (let i = 0; i < argv.length; i++) {
464
472
  const arg = argv[i];
465
473
  switch (arg) {
@@ -562,6 +570,9 @@ export function parseArgs(argv) {
562
570
  // @getindigo.ai identities for the first release.
563
571
  eventPush = true;
564
572
  break;
573
+ case "--qmd-full-refresh":
574
+ qmdFullRefresh = true;
575
+ break;
565
576
  case "--scope-path": {
566
577
  const val = argv[++i];
567
578
  if (!val)
@@ -634,6 +645,24 @@ export function parseArgs(argv) {
634
645
  lockTimeoutSec = n;
635
646
  break;
636
647
  }
648
+ case "--preflight-max-remote-deletes":
649
+ case "--preflight-max-local-deletes":
650
+ case "--preflight-max-pushes":
651
+ case "--preflight-max-pulls": {
652
+ const val = argv[++i];
653
+ if (val === undefined || !/^\d+$/.test(val) || !Number.isSafeInteger(Number(val))) {
654
+ return { error: `${arg} requires a non-negative safe integer` };
655
+ }
656
+ preflightRaw.push(`${arg.slice("--preflight-max-".length)}=${val}`);
657
+ break;
658
+ }
659
+ case "--audit-hash-out": {
660
+ const filename = argv[++i];
661
+ if (!filename)
662
+ return { error: "--audit-hash-out requires a file path" };
663
+ auditHashOut = filename;
664
+ break;
665
+ }
637
666
  default:
638
667
  return { error: `Unknown argument: ${arg}` };
639
668
  }
@@ -651,6 +680,39 @@ export function parseArgs(argv) {
651
680
  error: "--personal and --skip-personal are contradictory",
652
681
  };
653
682
  }
683
+ const preflightKeys = [
684
+ "remote-deletes",
685
+ "local-deletes",
686
+ "pushes",
687
+ "pulls",
688
+ ];
689
+ let preflightLimits;
690
+ if (preflightRaw.length > 0) {
691
+ const parsedLimits = Object.fromEntries(preflightRaw.map((entry) => {
692
+ const separator = entry.indexOf("=");
693
+ const key = entry.slice(0, separator);
694
+ const value = entry.slice(separator + 1);
695
+ return [key, Number(value)];
696
+ }));
697
+ if (preflightRaw.length !== 4 ||
698
+ preflightKeys.some((key) => !Object.hasOwn(parsedLimits, key))) {
699
+ return { error: "all four --preflight-max-* limits are required together" };
700
+ }
701
+ if (!company || companies || personal || watch || direction !== "both" || scopePaths.length > 0) {
702
+ return { error: "preflight limits require an unscoped, company-scoped, both-direction one-shot" };
703
+ }
704
+ if (!auditHashOut)
705
+ return { error: "preflight limits require --audit-hash-out" };
706
+ preflightLimits = {
707
+ maxRemoteDeletes: parsedLimits["remote-deletes"],
708
+ maxLocalDeletes: parsedLimits["local-deletes"],
709
+ maxPushes: parsedLimits.pushes,
710
+ maxPulls: parsedLimits.pulls,
711
+ };
712
+ }
713
+ if (auditHashOut && (!company || companies || personal || watch || direction !== "both" || scopePaths.length > 0)) {
714
+ return { error: "--audit-hash-out requires an unscoped, company-scoped, both-direction one-shot" };
715
+ }
654
716
  if (!companies && !company && !personal) {
655
717
  return { error: "Pass --companies, --company <slug>, or --personal" };
656
718
  }
@@ -697,6 +759,9 @@ export function parseArgs(argv) {
697
759
  deleteScopePaths,
698
760
  eventPush,
699
761
  lockTimeoutSec,
762
+ ...(qmdFullRefresh ? { qmdFullRefresh: true } : {}),
763
+ ...(preflightLimits ? { preflightLimits } : {}),
764
+ ...(auditHashOut ? { auditHashOut } : {}),
700
765
  };
701
766
  }
702
767
  // ---------------------------------------------------------------------------
@@ -987,6 +1052,7 @@ export async function runRunner(argv, deps = {}) {
987
1052
  stderr.write(`hq-sync-runner: ${parsed.error}\n`);
988
1053
  return 1;
989
1054
  }
1055
+ setAsyncJournalCompactionForWatchRunner(parsed.watch);
990
1056
  // A watch-loop pass also records its end-of-pass events for a host app that
991
1057
  // does not read this stdout (the desktop app when `hq daemon` runs sync).
992
1058
  const printEvent = createRunnerEmitter({ stdout, stderr });
@@ -1286,16 +1352,17 @@ export async function runRunner(argv, deps = {}) {
1286
1352
  for (const target of plan) {
1287
1353
  const journalSlug = target.journalSlug ?? target.slug;
1288
1354
  if (!journalTargets.has(journalSlug))
1289
- journalTargets.set(journalSlug, target.slug);
1355
+ journalTargets.set(journalSlug, { company: target.slug, companyUid: target.uid });
1290
1356
  }
1291
1357
  // The one-time personal-vault seed below reads the legacy `personal` shard
1292
1358
  // even when no real company with that slug appears in the fanout plan. Give
1293
1359
  // that source the same bounded recovery before migration can open it. A
1294
1360
  // missing shard is a metadata-only no-op.
1295
1361
  if (!journalTargets.has("personal"))
1296
- journalTargets.set("personal", "personal");
1362
+ journalTargets.set("personal", { company: "personal", companyUid: "" });
1297
1363
  const skippedJournalShards = new Set();
1298
- for (const [journalSlug, company] of journalTargets) {
1364
+ for (const [journalSlug, targetIdentity] of journalTargets) {
1365
+ const { company, companyUid } = targetIdentity;
1299
1366
  // Area-authoritative layouts rotate and repair through the watch loop's
1300
1367
  // bounded chore cursor. Do not put every area behind this pass's startup
1301
1368
  // preflight: a realtime scoped pass must be able to start before a
@@ -1306,6 +1373,7 @@ export async function runRunner(argv, deps = {}) {
1306
1373
  }
1307
1374
  try {
1308
1375
  repairJournalStateIfNeeded(journalSlug, {
1376
+ ...(companyUid ? { companyUid } : {}),
1309
1377
  onProgress: ({ bytesRead, totalBytes }) => {
1310
1378
  emit({
1311
1379
  type: "maintenance-progress",
@@ -1333,8 +1401,12 @@ export async function runRunner(argv, deps = {}) {
1333
1401
  }
1334
1402
  emit({
1335
1403
  type: "error",
1336
- message: err instanceof Error ? err.message : String(err),
1337
- path: "(local-state)",
1404
+ ...(companyUid ? { companyUid } : {}),
1405
+ errorClass: err instanceof Error && err.message.includes("HQSNAP4")
1406
+ ? "HQSNAP4EncodeError"
1407
+ : "JournalCompactionError",
1408
+ message: "journal recovery failed",
1409
+ path: "(journal)",
1338
1410
  });
1339
1411
  emitRunnerTerminalTelemetry({ status: "failed" });
1340
1412
  return 1;
@@ -1345,7 +1417,9 @@ export async function runRunner(argv, deps = {}) {
1345
1417
  // does NOT trigger a mass re-upload of the HQ overlay on first run. No-op
1346
1418
  // when the new journal already exists or the legacy one is absent, and
1347
1419
  // harmless even when the personal-vault slot is not in the plan.
1348
- if (!skippedJournalShards.has("personal") && !skippedJournalShards.has(PERSONAL_VAULT_JOURNAL_SLUG)) {
1420
+ if (!parsed.preflightLimits &&
1421
+ !skippedJournalShards.has("personal") &&
1422
+ !skippedJournalShards.has(PERSONAL_VAULT_JOURNAL_SLUG)) {
1349
1423
  (deps.migratePersonalVaultJournal ?? migratePersonalVaultJournal)();
1350
1424
  }
1351
1425
  // The journal preflight and personal-vault seed are the point at which an
@@ -1360,6 +1434,16 @@ export async function runRunner(argv, deps = {}) {
1360
1434
  // ---- fanout -----------------------------------------------------------
1361
1435
  const syncFn = deps.sync ?? defaultSync;
1362
1436
  const shareFn = deps.share ?? defaultShare;
1437
+ let audit;
1438
+ if (parsed.auditHashOut) {
1439
+ try {
1440
+ audit = new SyncRunnerHashAudit(parsed.auditHashOut);
1441
+ }
1442
+ catch {
1443
+ emit({ type: "error", path: "(audit)", message: "could not create hash audit output" });
1444
+ return 1;
1445
+ }
1446
+ }
1363
1447
  let fanout;
1364
1448
  try {
1365
1449
  const heartbeatCompany = plan[0]?.slug ?? "(fanout)";
@@ -1403,6 +1487,9 @@ export async function runRunner(argv, deps = {}) {
1403
1487
  fanout = await executeCompanyFanout({
1404
1488
  plan,
1405
1489
  direction: parsed.direction,
1490
+ ...(parsed.preflightLimits ? { preflightLimits: parsed.preflightLimits } : {}),
1491
+ grantInventoryComplete: parsed.companies,
1492
+ ...(audit ? { audit } : {}),
1406
1493
  hqRoot: parsed.hqRoot,
1407
1494
  onConflict: parsed.onConflict,
1408
1495
  client,
@@ -1416,6 +1503,16 @@ export async function runRunner(argv, deps = {}) {
1416
1503
  : {}),
1417
1504
  syncFn,
1418
1505
  shareFn,
1506
+ ...(deps.onCatchupSkip ? { onCatchupSkip: deps.onCatchupSkip } : {}),
1507
+ ...(deps.onCatchupGrantCompanies
1508
+ ? { onCatchupGrantCompanies: deps.onCatchupGrantCompanies }
1509
+ : {}),
1510
+ ...(deps.onCatchupGrantVersion
1511
+ ? { onCatchupGrantVersion: deps.onCatchupGrantVersion }
1512
+ : {}),
1513
+ ...(deps.onCatchupOntologyPush
1514
+ ? { onCatchupOntologyPush: deps.onCatchupOntologyPush }
1515
+ : {}),
1419
1516
  resolveDeletePolicy,
1420
1517
  emit,
1421
1518
  ...(emitFanoutHeartbeat ? { onHeartbeat: emitFanoutHeartbeat } : {}),
@@ -1443,6 +1540,7 @@ export async function runRunner(argv, deps = {}) {
1443
1540
  finally {
1444
1541
  if (heartbeatTimer)
1445
1542
  clearInterval(heartbeatTimer);
1543
+ audit?.close();
1446
1544
  }
1447
1545
  }
1448
1546
  catch (err) {
@@ -1468,6 +1566,8 @@ export async function runRunner(argv, deps = {}) {
1468
1566
  }
1469
1567
  throw err;
1470
1568
  }
1569
+ if (fanout.preflightHalted)
1570
+ return PREFLIGHT_HALT_EXIT;
1471
1571
  const { errors, transientCompanies, allConflicts, meaningfulOutcomeCount } = fanout;
1472
1572
  const rollup = rollupAllComplete(plan, fanout.stateByCompany);
1473
1573
  deps.onPassResult?.({
@@ -1689,82 +1789,131 @@ export async function runRunner(argv, deps = {}) {
1689
1789
  const qmdChangedPaths = rollup.changedPaths.filter(isQmdIndexableContentPath);
1690
1790
  if (qmdChangedPaths.length > 0 &&
1691
1791
  postSyncFlags.isEnabled(POST_SYNC_FLAG_KEYS.qmdReindex)) {
1692
- try {
1693
- const qmdResult = (deps.qmdReindex ?? reindexAfterSync)(parsed.hqRoot, {
1694
- changedPaths: qmdChangedPaths,
1695
- forceCollectionRefresh: true,
1696
- signal: deps.qmdSignal,
1697
- ...(deps.qmdHeartbeat
1698
- ? {
1699
- onHeartbeat: deps.qmdHeartbeat,
1700
- heartbeatIntervalMs: deps.qmdHeartbeatIntervalMs,
1701
- }
1702
- : {}),
1703
- log: ({ event, message, err, context }) => reportDiagnostic({
1704
- component: "qmd-reindex",
1705
- event,
1706
- message,
1707
- err,
1708
- context: { hqRoot: parsed.hqRoot, ...(context ?? {}) },
1709
- }),
1710
- });
1711
- const completedQmdResult = await qmdResult;
1712
- void emitCloudTelemetry(client, {
1713
- eventName: "qmd_reindex_run",
1714
- source: "sync-runner",
1715
- sessionId: telemetrySessionId,
1716
- properties: {
1717
- result: "completed",
1718
- changedCount: qmdChangedPaths.length,
1719
- qmdState: completedQmdResult.qmdAvailable ? "available" : "unavailable",
1720
- collectionsAddedCount: completedQmdResult.collectionsAdded.length,
1721
- pathDriftDetectedCount: completedQmdResult.pathDriftDetected?.length ?? 0,
1722
- collectionsRepairedCount: completedQmdResult.collectionsRepaired?.length ?? 0,
1723
- updateState: completedQmdResult.updated ? "updated" : "unchanged",
1724
- embedState: completedQmdResult.embedded ? "embedded" : "not_embedded",
1725
- dirtyState: completedQmdResult.pendingDirty ? "pending_dirty" : "clean",
1726
- lockBusy: completedQmdResult.lockBusy === true ? 1 : 0,
1727
- timedOut: completedQmdResult.timedOut === true ? 1 : 0,
1728
- corruptionQuarantined: completedQmdResult.corruptionQuarantined === true ? 1 : 0,
1729
- corruptionQuarantineFailed: completedQmdResult.corruptionQuarantineFailed === true ? 1 : 0,
1730
- },
1731
- }, {
1732
- claims,
1733
- log: (message) => reportDiagnostic({
1734
- component: "telemetry-events",
1735
- event: "runner.telemetry_events.nonfatal",
1736
- message,
1737
- context: { source: "sync-runner" },
1738
- }),
1739
- });
1740
- }
1741
- catch (err) {
1792
+ const qmdAdmission = deps.qmdAdmission;
1793
+ const qmdNow = qmdAdmission?.now ?? Date.now;
1794
+ const qmdPlatform = qmdAdmission?.platform ?? process.platform;
1795
+ const qmdEnv = qmdAdmission?.env ?? process.env;
1796
+ const qmdHome = qmdAdmission?.home ?? qmdEnv.HOME ?? os.homedir();
1797
+ const qmdRunnerFinishedByRoot = deps.qmdRunnerFinishedByRoot ?? (deps.qmdRunnerFinishedByRoot = new Map());
1798
+ const qmdLockHeld = isQmdBackgroundLockHeld(qmdHome, { nowMs: qmdNow() });
1799
+ if (qmdRunnerShouldSkip({
1800
+ platform: qmdPlatform,
1801
+ env: qmdEnv,
1802
+ nowMs: qmdNow(),
1803
+ lastFinishedAt: qmdRunnerLastFinishedAt(parsed.hqRoot, qmdRunnerFinishedByRoot),
1804
+ lockHeld: qmdLockHeld,
1805
+ fullRefresh: parsed.qmdFullRefresh,
1806
+ })) {
1742
1807
  reportDiagnostic({
1743
1808
  component: "qmd-reindex",
1744
- event: "runner.qmd_reindex.failed",
1745
- message: "post-sync qmd reindex threw",
1746
- err,
1747
- context: { hqRoot: parsed.hqRoot },
1748
- });
1749
- // Defensive: reindexAfterSync already swallows internally.
1750
- void emitCloudTelemetry(client, {
1751
- eventName: "qmd_reindex_run",
1752
- source: "sync-runner",
1753
- sessionId: telemetrySessionId,
1754
- properties: {
1755
- result: "threw",
1756
- changedCount: qmdChangedPaths.length,
1757
- },
1758
- }, {
1759
- claims,
1760
- log: (message) => reportDiagnostic({
1761
- component: "telemetry-events",
1762
- event: "runner.telemetry_events.nonfatal",
1763
- message,
1764
- context: { source: "sync-runner" },
1765
- }),
1809
+ event: "runner.qmd_reindex.skipped_admission",
1810
+ message: qmdLockHeld
1811
+ ? "post-sync qmd reindex skipped because the hq-cli background lock is held"
1812
+ : "post-sync qmd reindex skipped because the minimum interval has not elapsed",
1813
+ context: { hqRoot: parsed.hqRoot, lockBusy: qmdLockHeld },
1766
1814
  });
1767
1815
  }
1816
+ else {
1817
+ try {
1818
+ const releaseSharedLock = acquireQmdBackgroundLock(qmdHome, qmdNow());
1819
+ if (!releaseSharedLock) {
1820
+ reportDiagnostic({
1821
+ component: "qmd-reindex",
1822
+ event: "runner.qmd_reindex.skipped_admission",
1823
+ message: "post-sync qmd reindex skipped because the hq-cli background lock could not be acquired",
1824
+ context: { hqRoot: parsed.hqRoot, lockBusy: true },
1825
+ });
1826
+ }
1827
+ else {
1828
+ let completedQmdResult;
1829
+ try {
1830
+ const qmdResult = (deps.qmdReindex ?? reindexAfterSync)(parsed.hqRoot, {
1831
+ changedPaths: qmdChangedPaths,
1832
+ forceCollectionRefresh: true,
1833
+ signal: deps.qmdSignal,
1834
+ ...(deps.qmdHeartbeat
1835
+ ? {
1836
+ onHeartbeat: deps.qmdHeartbeat,
1837
+ heartbeatIntervalMs: deps.qmdHeartbeatIntervalMs,
1838
+ }
1839
+ : {}),
1840
+ log: ({ event, message, err, context }) => reportDiagnostic({
1841
+ component: "qmd-reindex",
1842
+ event,
1843
+ message,
1844
+ err,
1845
+ context: { hqRoot: parsed.hqRoot, ...(context ?? {}) },
1846
+ }),
1847
+ });
1848
+ completedQmdResult = await qmdResult;
1849
+ }
1850
+ finally {
1851
+ releaseSharedLock();
1852
+ }
1853
+ if (completedQmdResult.qmdAvailable &&
1854
+ completedQmdResult.updated &&
1855
+ completedQmdResult.lockBusy !== true &&
1856
+ completedQmdResult.timedOut !== true) {
1857
+ recordQmdRunnerFinished(parsed.hqRoot, qmdNow(), qmdRunnerFinishedByRoot);
1858
+ }
1859
+ void emitCloudTelemetry(client, {
1860
+ eventName: "qmd_reindex_run",
1861
+ source: "sync-runner",
1862
+ sessionId: telemetrySessionId,
1863
+ properties: {
1864
+ result: "completed",
1865
+ changedCount: qmdChangedPaths.length,
1866
+ qmdState: completedQmdResult.qmdAvailable ? "available" : "unavailable",
1867
+ collectionsAddedCount: completedQmdResult.collectionsAdded.length,
1868
+ pathDriftDetectedCount: completedQmdResult.pathDriftDetected?.length ?? 0,
1869
+ collectionsRepairedCount: completedQmdResult.collectionsRepaired?.length ?? 0,
1870
+ updateState: completedQmdResult.updated ? "updated" : "unchanged",
1871
+ embedState: completedQmdResult.embedded ? "embedded" : "not_embedded",
1872
+ dirtyState: completedQmdResult.pendingDirty ? "pending_dirty" : "clean",
1873
+ lockBusy: completedQmdResult.lockBusy === true ? 1 : 0,
1874
+ timedOut: completedQmdResult.timedOut === true ? 1 : 0,
1875
+ corruptionQuarantined: completedQmdResult.corruptionQuarantined === true ? 1 : 0,
1876
+ corruptionQuarantineFailed: completedQmdResult.corruptionQuarantineFailed === true ? 1 : 0,
1877
+ },
1878
+ }, {
1879
+ claims,
1880
+ log: (message) => reportDiagnostic({
1881
+ component: "telemetry-events",
1882
+ event: "runner.telemetry_events.nonfatal",
1883
+ message,
1884
+ context: { source: "sync-runner" },
1885
+ }),
1886
+ });
1887
+ }
1888
+ }
1889
+ catch (err) {
1890
+ reportDiagnostic({
1891
+ component: "qmd-reindex",
1892
+ event: "runner.qmd_reindex.failed",
1893
+ message: "post-sync qmd reindex threw",
1894
+ err,
1895
+ context: { hqRoot: parsed.hqRoot },
1896
+ });
1897
+ // Defensive: reindexAfterSync already swallows internally.
1898
+ void emitCloudTelemetry(client, {
1899
+ eventName: "qmd_reindex_run",
1900
+ source: "sync-runner",
1901
+ sessionId: telemetrySessionId,
1902
+ properties: {
1903
+ result: "threw",
1904
+ changedCount: qmdChangedPaths.length,
1905
+ },
1906
+ }, {
1907
+ claims,
1908
+ log: (message) => reportDiagnostic({
1909
+ component: "telemetry-events",
1910
+ event: "runner.telemetry_events.nonfatal",
1911
+ message,
1912
+ context: { source: "sync-runner" },
1913
+ }),
1914
+ });
1915
+ }
1916
+ }
1768
1917
  }
1769
1918
  // Post-sync conflict-ledger prune — also a best-effort tail step (runs AFTER
1770
1919
  // `all-complete`, never touches the exit code). The producers only append a
@@ -1989,6 +2138,16 @@ export function loopPassDeps(opts) {
1989
2138
  ...(opts.onJournalsReady ? { onJournalsReady: opts.onJournalsReady } : {}),
1990
2139
  ...(opts.onAppliedUpserts ? { onAppliedUpserts: opts.onAppliedUpserts } : {}),
1991
2140
  ...(opts.onAppliedDeletes ? { onAppliedDeletes: opts.onAppliedDeletes } : {}),
2141
+ ...(opts.onCatchupSkip ? { onCatchupSkip: opts.onCatchupSkip } : {}),
2142
+ ...(opts.onCatchupGrantCompanies
2143
+ ? { onCatchupGrantCompanies: opts.onCatchupGrantCompanies }
2144
+ : {}),
2145
+ ...(opts.onCatchupGrantVersion
2146
+ ? { onCatchupGrantVersion: opts.onCatchupGrantVersion }
2147
+ : {}),
2148
+ ...(opts.onCatchupOntologyPush
2149
+ ? { onCatchupOntologyPush: opts.onCatchupOntologyPush }
2150
+ : {}),
1992
2151
  ...(opts.watch ? { recordPassSummary: true } : {}),
1993
2152
  onPassResult: opts.onPassResult,
1994
2153
  };
@@ -2004,25 +2163,109 @@ function defaultParentExists(pid) {
2004
2163
  }
2005
2164
  export async function runRunnerWithLoop(argv, deps = {}) {
2006
2165
  const parsed = parseArgs(argv);
2166
+ const watcherCatchupSkipStore = deps.watcherCatchupSkipStore ?? ("error" in parsed
2167
+ ? undefined
2168
+ : new WatcherCatchupSkipStore(watcherCatchupSkipStorePath(getStateDir(), parsed.hqRoot)));
2169
+ const skipStoreHqRoot = "error" in parsed ? undefined : parsed.hqRoot;
2170
+ let lastSkipStorePruneAt = 0;
2171
+ let skipStorePruneWarningReported = false;
2007
2172
  const initialParentPid = process.ppid;
2008
2173
  const parentPid = "error" in parsed ? initialParentPid : (parsed.watchParentPid ?? initialParentPid);
2009
2174
  const runPassWithOperationLockAlreadyHeld = async (passArgv, fanoutHeartbeatIntervalMs, checkpoint, onJournalsReady, onAppliedUpserts, transferPriority = "chore", watch = false, qmdSignal, qmdHeartbeat, qmdHeartbeatIntervalMs, onAppliedDeletes) => {
2010
2175
  let result;
2011
- const exitCode = await runRunner(passArgv, loopPassDeps({
2012
- watch,
2013
- transferPriority,
2014
- qmdSignal,
2015
- qmdHeartbeat,
2016
- qmdHeartbeatIntervalMs,
2017
- fanoutHeartbeatIntervalMs,
2018
- checkpoint,
2019
- onJournalsReady,
2020
- onAppliedUpserts,
2021
- onAppliedDeletes,
2022
- onPassResult: (passResult) => {
2023
- result = passResult;
2024
- },
2025
- }));
2176
+ const passStartedAt = Date.now();
2177
+ const pendingCatchupSkips = [];
2178
+ let exitCode = 1;
2179
+ try {
2180
+ exitCode = await runRunner(passArgv, loopPassDeps({
2181
+ watch,
2182
+ transferPriority,
2183
+ qmdSignal,
2184
+ qmdHeartbeat,
2185
+ qmdHeartbeatIntervalMs,
2186
+ fanoutHeartbeatIntervalMs,
2187
+ checkpoint,
2188
+ onJournalsReady,
2189
+ onAppliedUpserts,
2190
+ onAppliedDeletes,
2191
+ ...(watcherCatchupSkipStore
2192
+ ? { onCatchupSkip: (record) => pendingCatchupSkips.push(record) }
2193
+ : {}),
2194
+ ...(watcherCatchupSkipStore
2195
+ ? {
2196
+ onCatchupGrantCompanies: (companies) => watcherCatchupSkipStore.setCurrentGrantCompanies(companies),
2197
+ }
2198
+ : {}),
2199
+ ...(watcherCatchupSkipStore
2200
+ ? {
2201
+ onCatchupOntologyPush: (company, enabled) => watcherCatchupSkipStore.setCurrentOntologyPush(company, enabled),
2202
+ }
2203
+ : {}),
2204
+ ...(watcherCatchupSkipStore
2205
+ ? {
2206
+ onCatchupGrantVersion: (company, grantVersion) => watcherCatchupSkipStore.setCurrentGrantVersion(company, grantVersion),
2207
+ }
2208
+ : {}),
2209
+ onPassResult: (passResult) => {
2210
+ result = passResult;
2211
+ },
2212
+ }));
2213
+ if (exitCode === 0) {
2214
+ watcherCatchupSkipStore?.clearResolvedGrantCompanies();
2215
+ const companyIndex = passArgv.indexOf("--company");
2216
+ const company = companyIndex >= 0 ? passArgv[companyIndex + 1]?.trim() : undefined;
2217
+ if (company)
2218
+ watcherCatchupSkipStore?.clearUnresolvedCompany(company);
2219
+ }
2220
+ }
2221
+ finally {
2222
+ if (watcherCatchupSkipStore &&
2223
+ skipStoreHqRoot &&
2224
+ Date.now() - lastSkipStorePruneAt >= 6 * 60 * 60 * 1_000) {
2225
+ watcherCatchupSkipStore.pruneMissing((record) => {
2226
+ const relativePath = record.company === "personal"
2227
+ ? record.path
2228
+ : ["companies", record.company, record.path].join("/");
2229
+ try {
2230
+ fs.lstatSync(localPathForVaultKey(skipStoreHqRoot, relativePath));
2231
+ return true;
2232
+ }
2233
+ catch (error) {
2234
+ const code = error.code;
2235
+ if (code === "ENOENT" || code === "ENOTDIR")
2236
+ return false;
2237
+ if (!skipStorePruneWarningReported) {
2238
+ skipStorePruneWarningReported = true;
2239
+ process.stderr.write(`hq-sync-runner: catch-up skip pruning could not inspect a local path; retaining record: ${error instanceof Error ? error.message : String(error)}\n`);
2240
+ }
2241
+ return true;
2242
+ }
2243
+ });
2244
+ lastSkipStorePruneAt = Date.now();
2245
+ }
2246
+ if (watcherCatchupSkipStore) {
2247
+ const recordedAt = Date.now();
2248
+ const unique = new Map();
2249
+ for (const record of pendingCatchupSkips) {
2250
+ unique.set(`${record.company}\u0000${record.path}`, record);
2251
+ }
2252
+ watcherCatchupSkipStore.commit([...unique.values()].map((record) => ({ ...record, recordedAt })));
2253
+ const counts = {
2254
+ "archived-session-log": 0,
2255
+ "access-denied": 0,
2256
+ "cap-evicted": watcherCatchupSkipStore.takeCapEvictedCount(),
2257
+ };
2258
+ for (const record of unique.values())
2259
+ counts[record.reason] += 1;
2260
+ process.stderr.write(`${JSON.stringify({
2261
+ type: "watcher-push-skip-summary",
2262
+ diagnostic: true,
2263
+ elapsedMs: Date.now() - passStartedAt,
2264
+ recordedPaths: unique.size,
2265
+ countsByReason: counts,
2266
+ })}\n`);
2267
+ }
2268
+ }
2026
2269
  return { exitCode, ...(result ? { result } : {}) };
2027
2270
  };
2028
2271
  const runtime = {
@@ -2051,6 +2294,15 @@ export async function runRunnerWithLoop(argv, deps = {}) {
2051
2294
  const ownerLease = ownerResult.lease;
2052
2295
  const restoreOperationLockSignalReraise = suppressOperationLockSignalReraise();
2053
2296
  const protocolOutput = deps.stdout ?? process.stdout;
2297
+ let latestSyncWorkState = {
2298
+ passActive: false,
2299
+ queuedWatcherPushes: 0,
2300
+ inFlightWatcherPushes: 0,
2301
+ pendingReceivers: 0,
2302
+ };
2303
+ const writeSyncWorkState = () => {
2304
+ protocolOutput.write(`${JSON.stringify({ type: "sync-work-state", ...latestSyncWorkState })}\n`);
2305
+ };
2054
2306
  let protocolStatus = { state: "starting" };
2055
2307
  const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
2056
2308
  eventLoopDelay.enable();
@@ -2110,6 +2362,7 @@ export async function runRunnerWithLoop(argv, deps = {}) {
2110
2362
  else {
2111
2363
  protocolOutput.write(line);
2112
2364
  }
2365
+ writeSyncWorkState();
2113
2366
  if (delay.maxMs > 2_000 && !stallReported) {
2114
2367
  stallReported = true;
2115
2368
  (deps.stderr ?? process.stderr).write(`${JSON.stringify({
@@ -2262,7 +2515,9 @@ export async function runRunnerWithLoop(argv, deps = {}) {
2262
2515
  while (!stopped) {
2263
2516
  const code = await runWatchLoop(argv, parsed, {
2264
2517
  ...deps,
2518
+ ...(watcherCatchupSkipStore ? { watcherCatchupSkipStore } : {}),
2265
2519
  reportProtocolStatus,
2520
+ onSyncWorkStateSnapshot: (snapshot) => { latestSyncWorkState = snapshot; },
2266
2521
  onShutdownSignal: (handler) => {
2267
2522
  innerShutdown = handler;
2268
2523
  if (stopped)