@indigoai-us/hq-cloud 6.18.53 → 6.18.55-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 (87) 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 +348 -94
  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/share.d.ts +27 -0
  26. package/dist/cli/share.d.ts.map +1 -1
  27. package/dist/cli/share.js +108 -15
  28. package/dist/cli/share.js.map +1 -1
  29. package/dist/cli/sync.d.ts +18 -2
  30. package/dist/cli/sync.d.ts.map +1 -1
  31. package/dist/cli/sync.js +74 -14
  32. package/dist/cli/sync.js.map +1 -1
  33. package/dist/ignore.d.ts +1 -1
  34. package/dist/ignore.d.ts.map +1 -1
  35. package/dist/ignore.js +2 -2
  36. package/dist/ignore.js.map +1 -1
  37. package/dist/journal-snapshot-v4.d.ts.map +1 -1
  38. package/dist/journal-snapshot-v4.js +44 -38
  39. package/dist/journal-snapshot-v4.js.map +1 -1
  40. package/dist/journal.d.ts +15 -2
  41. package/dist/journal.d.ts.map +1 -1
  42. package/dist/journal.js +221 -16
  43. package/dist/journal.js.map +1 -1
  44. package/dist/lib/cloud-authoritative.d.ts +9 -2
  45. package/dist/lib/cloud-authoritative.d.ts.map +1 -1
  46. package/dist/lib/cloud-authoritative.js +34 -2
  47. package/dist/lib/cloud-authoritative.js.map +1 -1
  48. package/dist/lib/exit-codes.d.ts +2 -0
  49. package/dist/lib/exit-codes.d.ts.map +1 -1
  50. package/dist/lib/exit-codes.js +2 -0
  51. package/dist/lib/exit-codes.js.map +1 -1
  52. package/dist/personal-vault-exclusions.d.ts +2 -2
  53. package/dist/personal-vault-exclusions.d.ts.map +1 -1
  54. package/dist/personal-vault-exclusions.js +5 -3
  55. package/dist/personal-vault-exclusions.js.map +1 -1
  56. package/dist/personal-vault.d.ts +7 -0
  57. package/dist/personal-vault.d.ts.map +1 -1
  58. package/dist/personal-vault.js +10 -0
  59. package/dist/personal-vault.js.map +1 -1
  60. package/dist/qmd-runner-admission.d.ts +22 -0
  61. package/dist/qmd-runner-admission.d.ts.map +1 -0
  62. package/dist/qmd-runner-admission.js +107 -0
  63. package/dist/qmd-runner-admission.js.map +1 -0
  64. package/dist/symlink-target-policy.d.ts +11 -0
  65. package/dist/symlink-target-policy.d.ts.map +1 -1
  66. package/dist/symlink-target-policy.js +110 -0
  67. package/dist/symlink-target-policy.js.map +1 -1
  68. package/dist/sync/mutation-session.d.ts.map +1 -1
  69. package/dist/sync/mutation-session.js +4 -0
  70. package/dist/sync/mutation-session.js.map +1 -1
  71. package/dist/sync/parallel-sync-coordinator.d.ts +29 -0
  72. package/dist/sync/parallel-sync-coordinator.d.ts.map +1 -1
  73. package/dist/sync/parallel-sync-coordinator.js +229 -16
  74. package/dist/sync/parallel-sync-coordinator.js.map +1 -1
  75. package/dist/sync/push-receiver.d.ts +5 -0
  76. package/dist/sync/push-receiver.d.ts.map +1 -1
  77. package/dist/sync/push-receiver.js +32 -10
  78. package/dist/sync/push-receiver.js.map +1 -1
  79. package/dist/watch-owner-lease.d.ts +4 -0
  80. package/dist/watch-owner-lease.d.ts.map +1 -1
  81. package/dist/watch-owner-lease.js +40 -6
  82. package/dist/watch-owner-lease.js.map +1 -1
  83. package/dist/watcher.d.ts +16 -3
  84. package/dist/watcher.d.ts.map +1 -1
  85. package/dist/watcher.js +114 -21
  86. package/dist/watcher.js.map +1 -1
  87. package/package.json +1 -1
@@ -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,7 +105,8 @@ 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";
@@ -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
  // ---------------------------------------------------------------------------
@@ -1286,16 +1351,17 @@ export async function runRunner(argv, deps = {}) {
1286
1351
  for (const target of plan) {
1287
1352
  const journalSlug = target.journalSlug ?? target.slug;
1288
1353
  if (!journalTargets.has(journalSlug))
1289
- journalTargets.set(journalSlug, target.slug);
1354
+ journalTargets.set(journalSlug, { company: target.slug, companyUid: target.uid });
1290
1355
  }
1291
1356
  // The one-time personal-vault seed below reads the legacy `personal` shard
1292
1357
  // even when no real company with that slug appears in the fanout plan. Give
1293
1358
  // that source the same bounded recovery before migration can open it. A
1294
1359
  // missing shard is a metadata-only no-op.
1295
1360
  if (!journalTargets.has("personal"))
1296
- journalTargets.set("personal", "personal");
1361
+ journalTargets.set("personal", { company: "personal", companyUid: "" });
1297
1362
  const skippedJournalShards = new Set();
1298
- for (const [journalSlug, company] of journalTargets) {
1363
+ for (const [journalSlug, targetIdentity] of journalTargets) {
1364
+ const { company, companyUid } = targetIdentity;
1299
1365
  // Area-authoritative layouts rotate and repair through the watch loop's
1300
1366
  // bounded chore cursor. Do not put every area behind this pass's startup
1301
1367
  // preflight: a realtime scoped pass must be able to start before a
@@ -1306,6 +1372,7 @@ export async function runRunner(argv, deps = {}) {
1306
1372
  }
1307
1373
  try {
1308
1374
  repairJournalStateIfNeeded(journalSlug, {
1375
+ ...(companyUid ? { companyUid } : {}),
1309
1376
  onProgress: ({ bytesRead, totalBytes }) => {
1310
1377
  emit({
1311
1378
  type: "maintenance-progress",
@@ -1333,8 +1400,12 @@ export async function runRunner(argv, deps = {}) {
1333
1400
  }
1334
1401
  emit({
1335
1402
  type: "error",
1336
- message: err instanceof Error ? err.message : String(err),
1337
- path: "(local-state)",
1403
+ ...(companyUid ? { companyUid } : {}),
1404
+ errorClass: err instanceof Error && err.message.includes("HQSNAP4")
1405
+ ? "HQSNAP4EncodeError"
1406
+ : "JournalCompactionError",
1407
+ message: "journal recovery failed",
1408
+ path: "(journal)",
1338
1409
  });
1339
1410
  emitRunnerTerminalTelemetry({ status: "failed" });
1340
1411
  return 1;
@@ -1345,7 +1416,9 @@ export async function runRunner(argv, deps = {}) {
1345
1416
  // does NOT trigger a mass re-upload of the HQ overlay on first run. No-op
1346
1417
  // when the new journal already exists or the legacy one is absent, and
1347
1418
  // harmless even when the personal-vault slot is not in the plan.
1348
- if (!skippedJournalShards.has("personal") && !skippedJournalShards.has(PERSONAL_VAULT_JOURNAL_SLUG)) {
1419
+ if (!parsed.preflightLimits &&
1420
+ !skippedJournalShards.has("personal") &&
1421
+ !skippedJournalShards.has(PERSONAL_VAULT_JOURNAL_SLUG)) {
1349
1422
  (deps.migratePersonalVaultJournal ?? migratePersonalVaultJournal)();
1350
1423
  }
1351
1424
  // The journal preflight and personal-vault seed are the point at which an
@@ -1360,6 +1433,16 @@ export async function runRunner(argv, deps = {}) {
1360
1433
  // ---- fanout -----------------------------------------------------------
1361
1434
  const syncFn = deps.sync ?? defaultSync;
1362
1435
  const shareFn = deps.share ?? defaultShare;
1436
+ let audit;
1437
+ if (parsed.auditHashOut) {
1438
+ try {
1439
+ audit = new SyncRunnerHashAudit(parsed.auditHashOut);
1440
+ }
1441
+ catch {
1442
+ emit({ type: "error", path: "(audit)", message: "could not create hash audit output" });
1443
+ return 1;
1444
+ }
1445
+ }
1363
1446
  let fanout;
1364
1447
  try {
1365
1448
  const heartbeatCompany = plan[0]?.slug ?? "(fanout)";
@@ -1403,6 +1486,9 @@ export async function runRunner(argv, deps = {}) {
1403
1486
  fanout = await executeCompanyFanout({
1404
1487
  plan,
1405
1488
  direction: parsed.direction,
1489
+ ...(parsed.preflightLimits ? { preflightLimits: parsed.preflightLimits } : {}),
1490
+ grantInventoryComplete: parsed.companies,
1491
+ ...(audit ? { audit } : {}),
1406
1492
  hqRoot: parsed.hqRoot,
1407
1493
  onConflict: parsed.onConflict,
1408
1494
  client,
@@ -1416,6 +1502,16 @@ export async function runRunner(argv, deps = {}) {
1416
1502
  : {}),
1417
1503
  syncFn,
1418
1504
  shareFn,
1505
+ ...(deps.onCatchupSkip ? { onCatchupSkip: deps.onCatchupSkip } : {}),
1506
+ ...(deps.onCatchupGrantCompanies
1507
+ ? { onCatchupGrantCompanies: deps.onCatchupGrantCompanies }
1508
+ : {}),
1509
+ ...(deps.onCatchupGrantVersion
1510
+ ? { onCatchupGrantVersion: deps.onCatchupGrantVersion }
1511
+ : {}),
1512
+ ...(deps.onCatchupOntologyPush
1513
+ ? { onCatchupOntologyPush: deps.onCatchupOntologyPush }
1514
+ : {}),
1419
1515
  resolveDeletePolicy,
1420
1516
  emit,
1421
1517
  ...(emitFanoutHeartbeat ? { onHeartbeat: emitFanoutHeartbeat } : {}),
@@ -1443,6 +1539,7 @@ export async function runRunner(argv, deps = {}) {
1443
1539
  finally {
1444
1540
  if (heartbeatTimer)
1445
1541
  clearInterval(heartbeatTimer);
1542
+ audit?.close();
1446
1543
  }
1447
1544
  }
1448
1545
  catch (err) {
@@ -1468,6 +1565,8 @@ export async function runRunner(argv, deps = {}) {
1468
1565
  }
1469
1566
  throw err;
1470
1567
  }
1568
+ if (fanout.preflightHalted)
1569
+ return PREFLIGHT_HALT_EXIT;
1471
1570
  const { errors, transientCompanies, allConflicts, meaningfulOutcomeCount } = fanout;
1472
1571
  const rollup = rollupAllComplete(plan, fanout.stateByCompany);
1473
1572
  deps.onPassResult?.({
@@ -1689,82 +1788,131 @@ export async function runRunner(argv, deps = {}) {
1689
1788
  const qmdChangedPaths = rollup.changedPaths.filter(isQmdIndexableContentPath);
1690
1789
  if (qmdChangedPaths.length > 0 &&
1691
1790
  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) {
1791
+ const qmdAdmission = deps.qmdAdmission;
1792
+ const qmdNow = qmdAdmission?.now ?? Date.now;
1793
+ const qmdPlatform = qmdAdmission?.platform ?? process.platform;
1794
+ const qmdEnv = qmdAdmission?.env ?? process.env;
1795
+ const qmdHome = qmdAdmission?.home ?? qmdEnv.HOME ?? os.homedir();
1796
+ const qmdRunnerFinishedByRoot = deps.qmdRunnerFinishedByRoot ?? (deps.qmdRunnerFinishedByRoot = new Map());
1797
+ const qmdLockHeld = isQmdBackgroundLockHeld(qmdHome, { nowMs: qmdNow() });
1798
+ if (qmdRunnerShouldSkip({
1799
+ platform: qmdPlatform,
1800
+ env: qmdEnv,
1801
+ nowMs: qmdNow(),
1802
+ lastFinishedAt: qmdRunnerLastFinishedAt(parsed.hqRoot, qmdRunnerFinishedByRoot),
1803
+ lockHeld: qmdLockHeld,
1804
+ fullRefresh: parsed.qmdFullRefresh,
1805
+ })) {
1742
1806
  reportDiagnostic({
1743
1807
  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
- }),
1808
+ event: "runner.qmd_reindex.skipped_admission",
1809
+ message: qmdLockHeld
1810
+ ? "post-sync qmd reindex skipped because the hq-cli background lock is held"
1811
+ : "post-sync qmd reindex skipped because the minimum interval has not elapsed",
1812
+ context: { hqRoot: parsed.hqRoot, lockBusy: qmdLockHeld },
1766
1813
  });
1767
1814
  }
1815
+ else {
1816
+ try {
1817
+ const releaseSharedLock = acquireQmdBackgroundLock(qmdHome, qmdNow());
1818
+ if (!releaseSharedLock) {
1819
+ reportDiagnostic({
1820
+ component: "qmd-reindex",
1821
+ event: "runner.qmd_reindex.skipped_admission",
1822
+ message: "post-sync qmd reindex skipped because the hq-cli background lock could not be acquired",
1823
+ context: { hqRoot: parsed.hqRoot, lockBusy: true },
1824
+ });
1825
+ }
1826
+ else {
1827
+ let completedQmdResult;
1828
+ try {
1829
+ const qmdResult = (deps.qmdReindex ?? reindexAfterSync)(parsed.hqRoot, {
1830
+ changedPaths: qmdChangedPaths,
1831
+ forceCollectionRefresh: true,
1832
+ signal: deps.qmdSignal,
1833
+ ...(deps.qmdHeartbeat
1834
+ ? {
1835
+ onHeartbeat: deps.qmdHeartbeat,
1836
+ heartbeatIntervalMs: deps.qmdHeartbeatIntervalMs,
1837
+ }
1838
+ : {}),
1839
+ log: ({ event, message, err, context }) => reportDiagnostic({
1840
+ component: "qmd-reindex",
1841
+ event,
1842
+ message,
1843
+ err,
1844
+ context: { hqRoot: parsed.hqRoot, ...(context ?? {}) },
1845
+ }),
1846
+ });
1847
+ completedQmdResult = await qmdResult;
1848
+ }
1849
+ finally {
1850
+ releaseSharedLock();
1851
+ }
1852
+ if (completedQmdResult.qmdAvailable &&
1853
+ completedQmdResult.updated &&
1854
+ completedQmdResult.lockBusy !== true &&
1855
+ completedQmdResult.timedOut !== true) {
1856
+ recordQmdRunnerFinished(parsed.hqRoot, qmdNow(), qmdRunnerFinishedByRoot);
1857
+ }
1858
+ void emitCloudTelemetry(client, {
1859
+ eventName: "qmd_reindex_run",
1860
+ source: "sync-runner",
1861
+ sessionId: telemetrySessionId,
1862
+ properties: {
1863
+ result: "completed",
1864
+ changedCount: qmdChangedPaths.length,
1865
+ qmdState: completedQmdResult.qmdAvailable ? "available" : "unavailable",
1866
+ collectionsAddedCount: completedQmdResult.collectionsAdded.length,
1867
+ pathDriftDetectedCount: completedQmdResult.pathDriftDetected?.length ?? 0,
1868
+ collectionsRepairedCount: completedQmdResult.collectionsRepaired?.length ?? 0,
1869
+ updateState: completedQmdResult.updated ? "updated" : "unchanged",
1870
+ embedState: completedQmdResult.embedded ? "embedded" : "not_embedded",
1871
+ dirtyState: completedQmdResult.pendingDirty ? "pending_dirty" : "clean",
1872
+ lockBusy: completedQmdResult.lockBusy === true ? 1 : 0,
1873
+ timedOut: completedQmdResult.timedOut === true ? 1 : 0,
1874
+ corruptionQuarantined: completedQmdResult.corruptionQuarantined === true ? 1 : 0,
1875
+ corruptionQuarantineFailed: completedQmdResult.corruptionQuarantineFailed === true ? 1 : 0,
1876
+ },
1877
+ }, {
1878
+ claims,
1879
+ log: (message) => reportDiagnostic({
1880
+ component: "telemetry-events",
1881
+ event: "runner.telemetry_events.nonfatal",
1882
+ message,
1883
+ context: { source: "sync-runner" },
1884
+ }),
1885
+ });
1886
+ }
1887
+ }
1888
+ catch (err) {
1889
+ reportDiagnostic({
1890
+ component: "qmd-reindex",
1891
+ event: "runner.qmd_reindex.failed",
1892
+ message: "post-sync qmd reindex threw",
1893
+ err,
1894
+ context: { hqRoot: parsed.hqRoot },
1895
+ });
1896
+ // Defensive: reindexAfterSync already swallows internally.
1897
+ void emitCloudTelemetry(client, {
1898
+ eventName: "qmd_reindex_run",
1899
+ source: "sync-runner",
1900
+ sessionId: telemetrySessionId,
1901
+ properties: {
1902
+ result: "threw",
1903
+ changedCount: qmdChangedPaths.length,
1904
+ },
1905
+ }, {
1906
+ claims,
1907
+ log: (message) => reportDiagnostic({
1908
+ component: "telemetry-events",
1909
+ event: "runner.telemetry_events.nonfatal",
1910
+ message,
1911
+ context: { source: "sync-runner" },
1912
+ }),
1913
+ });
1914
+ }
1915
+ }
1768
1916
  }
1769
1917
  // Post-sync conflict-ledger prune — also a best-effort tail step (runs AFTER
1770
1918
  // `all-complete`, never touches the exit code). The producers only append a
@@ -1989,6 +2137,16 @@ export function loopPassDeps(opts) {
1989
2137
  ...(opts.onJournalsReady ? { onJournalsReady: opts.onJournalsReady } : {}),
1990
2138
  ...(opts.onAppliedUpserts ? { onAppliedUpserts: opts.onAppliedUpserts } : {}),
1991
2139
  ...(opts.onAppliedDeletes ? { onAppliedDeletes: opts.onAppliedDeletes } : {}),
2140
+ ...(opts.onCatchupSkip ? { onCatchupSkip: opts.onCatchupSkip } : {}),
2141
+ ...(opts.onCatchupGrantCompanies
2142
+ ? { onCatchupGrantCompanies: opts.onCatchupGrantCompanies }
2143
+ : {}),
2144
+ ...(opts.onCatchupGrantVersion
2145
+ ? { onCatchupGrantVersion: opts.onCatchupGrantVersion }
2146
+ : {}),
2147
+ ...(opts.onCatchupOntologyPush
2148
+ ? { onCatchupOntologyPush: opts.onCatchupOntologyPush }
2149
+ : {}),
1992
2150
  ...(opts.watch ? { recordPassSummary: true } : {}),
1993
2151
  onPassResult: opts.onPassResult,
1994
2152
  };
@@ -2004,25 +2162,109 @@ function defaultParentExists(pid) {
2004
2162
  }
2005
2163
  export async function runRunnerWithLoop(argv, deps = {}) {
2006
2164
  const parsed = parseArgs(argv);
2165
+ const watcherCatchupSkipStore = deps.watcherCatchupSkipStore ?? ("error" in parsed
2166
+ ? undefined
2167
+ : new WatcherCatchupSkipStore(watcherCatchupSkipStorePath(getStateDir(), parsed.hqRoot)));
2168
+ const skipStoreHqRoot = "error" in parsed ? undefined : parsed.hqRoot;
2169
+ let lastSkipStorePruneAt = 0;
2170
+ let skipStorePruneWarningReported = false;
2007
2171
  const initialParentPid = process.ppid;
2008
2172
  const parentPid = "error" in parsed ? initialParentPid : (parsed.watchParentPid ?? initialParentPid);
2009
2173
  const runPassWithOperationLockAlreadyHeld = async (passArgv, fanoutHeartbeatIntervalMs, checkpoint, onJournalsReady, onAppliedUpserts, transferPriority = "chore", watch = false, qmdSignal, qmdHeartbeat, qmdHeartbeatIntervalMs, onAppliedDeletes) => {
2010
2174
  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
- }));
2175
+ const passStartedAt = Date.now();
2176
+ const pendingCatchupSkips = [];
2177
+ let exitCode = 1;
2178
+ try {
2179
+ exitCode = await runRunner(passArgv, loopPassDeps({
2180
+ watch,
2181
+ transferPriority,
2182
+ qmdSignal,
2183
+ qmdHeartbeat,
2184
+ qmdHeartbeatIntervalMs,
2185
+ fanoutHeartbeatIntervalMs,
2186
+ checkpoint,
2187
+ onJournalsReady,
2188
+ onAppliedUpserts,
2189
+ onAppliedDeletes,
2190
+ ...(watcherCatchupSkipStore
2191
+ ? { onCatchupSkip: (record) => pendingCatchupSkips.push(record) }
2192
+ : {}),
2193
+ ...(watcherCatchupSkipStore
2194
+ ? {
2195
+ onCatchupGrantCompanies: (companies) => watcherCatchupSkipStore.setCurrentGrantCompanies(companies),
2196
+ }
2197
+ : {}),
2198
+ ...(watcherCatchupSkipStore
2199
+ ? {
2200
+ onCatchupOntologyPush: (company, enabled) => watcherCatchupSkipStore.setCurrentOntologyPush(company, enabled),
2201
+ }
2202
+ : {}),
2203
+ ...(watcherCatchupSkipStore
2204
+ ? {
2205
+ onCatchupGrantVersion: (company, grantVersion) => watcherCatchupSkipStore.setCurrentGrantVersion(company, grantVersion),
2206
+ }
2207
+ : {}),
2208
+ onPassResult: (passResult) => {
2209
+ result = passResult;
2210
+ },
2211
+ }));
2212
+ if (exitCode === 0) {
2213
+ watcherCatchupSkipStore?.clearResolvedGrantCompanies();
2214
+ const companyIndex = passArgv.indexOf("--company");
2215
+ const company = companyIndex >= 0 ? passArgv[companyIndex + 1]?.trim() : undefined;
2216
+ if (company)
2217
+ watcherCatchupSkipStore?.clearUnresolvedCompany(company);
2218
+ }
2219
+ }
2220
+ finally {
2221
+ if (watcherCatchupSkipStore &&
2222
+ skipStoreHqRoot &&
2223
+ Date.now() - lastSkipStorePruneAt >= 6 * 60 * 60 * 1_000) {
2224
+ watcherCatchupSkipStore.pruneMissing((record) => {
2225
+ const relativePath = record.company === "personal"
2226
+ ? record.path
2227
+ : ["companies", record.company, record.path].join("/");
2228
+ try {
2229
+ fs.lstatSync(localPathForVaultKey(skipStoreHqRoot, relativePath));
2230
+ return true;
2231
+ }
2232
+ catch (error) {
2233
+ const code = error.code;
2234
+ if (code === "ENOENT" || code === "ENOTDIR")
2235
+ return false;
2236
+ if (!skipStorePruneWarningReported) {
2237
+ skipStorePruneWarningReported = true;
2238
+ 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`);
2239
+ }
2240
+ return true;
2241
+ }
2242
+ });
2243
+ lastSkipStorePruneAt = Date.now();
2244
+ }
2245
+ if (watcherCatchupSkipStore) {
2246
+ const recordedAt = Date.now();
2247
+ const unique = new Map();
2248
+ for (const record of pendingCatchupSkips) {
2249
+ unique.set(`${record.company}\u0000${record.path}`, record);
2250
+ }
2251
+ watcherCatchupSkipStore.commit([...unique.values()].map((record) => ({ ...record, recordedAt })));
2252
+ const counts = {
2253
+ "archived-session-log": 0,
2254
+ "access-denied": 0,
2255
+ "cap-evicted": watcherCatchupSkipStore.takeCapEvictedCount(),
2256
+ };
2257
+ for (const record of unique.values())
2258
+ counts[record.reason] += 1;
2259
+ process.stderr.write(`${JSON.stringify({
2260
+ type: "watcher-push-skip-summary",
2261
+ diagnostic: true,
2262
+ elapsedMs: Date.now() - passStartedAt,
2263
+ recordedPaths: unique.size,
2264
+ countsByReason: counts,
2265
+ })}\n`);
2266
+ }
2267
+ }
2026
2268
  return { exitCode, ...(result ? { result } : {}) };
2027
2269
  };
2028
2270
  const runtime = {
@@ -2051,6 +2293,15 @@ export async function runRunnerWithLoop(argv, deps = {}) {
2051
2293
  const ownerLease = ownerResult.lease;
2052
2294
  const restoreOperationLockSignalReraise = suppressOperationLockSignalReraise();
2053
2295
  const protocolOutput = deps.stdout ?? process.stdout;
2296
+ let latestSyncWorkState = {
2297
+ passActive: false,
2298
+ queuedWatcherPushes: 0,
2299
+ inFlightWatcherPushes: 0,
2300
+ pendingReceivers: 0,
2301
+ };
2302
+ const writeSyncWorkState = () => {
2303
+ protocolOutput.write(`${JSON.stringify({ type: "sync-work-state", ...latestSyncWorkState })}\n`);
2304
+ };
2054
2305
  let protocolStatus = { state: "starting" };
2055
2306
  const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
2056
2307
  eventLoopDelay.enable();
@@ -2110,6 +2361,7 @@ export async function runRunnerWithLoop(argv, deps = {}) {
2110
2361
  else {
2111
2362
  protocolOutput.write(line);
2112
2363
  }
2364
+ writeSyncWorkState();
2113
2365
  if (delay.maxMs > 2_000 && !stallReported) {
2114
2366
  stallReported = true;
2115
2367
  (deps.stderr ?? process.stderr).write(`${JSON.stringify({
@@ -2262,7 +2514,9 @@ export async function runRunnerWithLoop(argv, deps = {}) {
2262
2514
  while (!stopped) {
2263
2515
  const code = await runWatchLoop(argv, parsed, {
2264
2516
  ...deps,
2517
+ ...(watcherCatchupSkipStore ? { watcherCatchupSkipStore } : {}),
2265
2518
  reportProtocolStatus,
2519
+ onSyncWorkStateSnapshot: (snapshot) => { latestSyncWorkState = snapshot; },
2266
2520
  onShutdownSignal: (handler) => {
2267
2521
  innerShutdown = handler;
2268
2522
  if (stopped)