@merittdev/horus 0.8.0 → 0.8.2

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 (2) hide show
  1. package/dist/index.cjs +143 -21
  2. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -65880,8 +65880,8 @@ init_cjs_shims();
65880
65880
 
65881
65881
  // ../../packages/core/src/version.ts
65882
65882
  init_cjs_shims();
65883
- var HORUS_VERSION = true ? "0.8.0" : "dev";
65884
- var PINNED_SOURCE_VERSION = "1.0.7";
65883
+ var HORUS_VERSION = true ? "0.8.2" : "dev";
65884
+ var PINNED_SOURCE_VERSION = "1.5.0";
65885
65885
 
65886
65886
  // ../../packages/core/src/evidence.ts
65887
65887
  init_cjs_shims();
@@ -70979,6 +70979,24 @@ async function getSourceVersion() {
70979
70979
  return null;
70980
70980
  }
70981
70981
  }
70982
+ var SourceVersionMismatchError = class extends Error {
70983
+ constructor(installed, pinned) {
70984
+ super(
70985
+ `horus-source ${installed} is installed but Horus is pinned to ${pinned}. A drifted backend builds a graph this CLI cannot map and can corrupt the index (e.g. duplicate-primary-key failures during "Running initial index"). Install the pinned version: pip install 'horus-source==${pinned}'`
70986
+ );
70987
+ this.installed = installed;
70988
+ this.pinned = pinned;
70989
+ this.name = "SourceVersionMismatchError";
70990
+ }
70991
+ installed;
70992
+ pinned;
70993
+ };
70994
+ async function assertSourceVersionPinned() {
70995
+ const installed = await getSourceVersion();
70996
+ if (installed !== null && installed !== PINNED_SOURCE_VERSION) {
70997
+ throw new SourceVersionMismatchError(installed, PINNED_SOURCE_VERSION);
70998
+ }
70999
+ }
70982
71000
  function isAnalyzed(root) {
70983
71001
  return (0, import_node_fs3.existsSync)((0, import_node_path3.join)(root, ".horus", "source"));
70984
71002
  }
@@ -71001,6 +71019,21 @@ function readSourceHostUrl(root) {
71001
71019
  return null;
71002
71020
  }
71003
71021
  }
71022
+ function readSourceHostPid(root) {
71023
+ const p5 = (0, import_node_path3.join)(root, ".horus", "source", "host.json");
71024
+ if (!(0, import_node_fs3.existsSync)(p5)) return null;
71025
+ try {
71026
+ const j3 = JSON.parse((0, import_node_fs3.readFileSync)(p5, "utf8"));
71027
+ if (typeof j3.pid !== "number" || !Number.isInteger(j3.pid) || j3.pid <= 0) return null;
71028
+ return {
71029
+ pid: j3.pid,
71030
+ port: typeof j3.port === "number" ? j3.port : NaN,
71031
+ repoPath: typeof j3.repo_path === "string" ? j3.repo_path : ""
71032
+ };
71033
+ } catch {
71034
+ return null;
71035
+ }
71036
+ }
71004
71037
  async function isHostHealthy(hostUrl) {
71005
71038
  try {
71006
71039
  const res = await fetch(`${hostUrl}/api/health`, {
@@ -71067,6 +71100,21 @@ function removeSpawnedHostRecord(root) {
71067
71100
  } catch {
71068
71101
  }
71069
71102
  }
71103
+ function reconcileSpawnedHostPid(root, port) {
71104
+ try {
71105
+ const backend = readSourceHostPid(root);
71106
+ if (!backend || !Number.isInteger(backend.pid) || backend.pid <= 0) return;
71107
+ if (Number.isFinite(backend.port) && backend.port !== port) return;
71108
+ const spawned = readSpawnedHost(root);
71109
+ if (!spawned || spawned.pid === backend.pid) return;
71110
+ const updated = { ...spawned, pid: backend.pid };
71111
+ (0, import_node_fs3.writeFileSync)(
71112
+ (0, import_node_path3.join)(root, ".horus", SPAWNED_HOST_FILE),
71113
+ JSON.stringify(updated, null, 2) + "\n"
71114
+ );
71115
+ } catch {
71116
+ }
71117
+ }
71070
71118
  async function waitForHost(hostUrl, timeoutMs = 45e3) {
71071
71119
  const deadline = Date.now() + timeoutMs;
71072
71120
  while (Date.now() < deadline) {
@@ -97105,6 +97153,12 @@ async function runIndex(opts) {
97105
97153
  console.error(import_picocolors3.default.red("horus-source not found on PATH. Install it: pip install horus-source"));
97106
97154
  return 1;
97107
97155
  }
97156
+ try {
97157
+ await assertSourceVersionPinned();
97158
+ } catch (err2) {
97159
+ console.error(import_picocolors3.default.red(` ${err2.message}`));
97160
+ return 1;
97161
+ }
97108
97162
  if (!isAnalyzed(root)) {
97109
97163
  console.log(import_picocolors3.default.dim(" analyzing with source-intelligence backend (first time \u2014 this can take a while)\u2026"));
97110
97164
  try {
@@ -97127,6 +97181,7 @@ async function runIndex(opts) {
97127
97181
  );
97128
97182
  return 1;
97129
97183
  }
97184
+ reconcileSpawnedHostPid(root, port);
97130
97185
  }
97131
97186
  await stitchQueueMap(hostUrl, dbUrl, label);
97132
97187
  buildKnowledgeIndex(root, config2, label, configuredProject, opts);
@@ -110066,6 +110121,8 @@ function ensureHostReasonHint(reason) {
110066
110121
  switch (reason) {
110067
110122
  case "source-unavailable":
110068
110123
  return "horus-source is not installed \u2014 install it: pip install horus-source";
110124
+ case "version-mismatch":
110125
+ return "the installed horus-source backend does not match the version Horus is pinned to \u2014 reinstall the pinned version (run `horus status` to see it)";
110069
110126
  case "not-analyzed":
110070
110127
  return "this repo has not been indexed yet \u2014 run: horus index";
110071
110128
  case "bad-url":
@@ -110077,11 +110134,19 @@ function ensureHostReasonHint(reason) {
110077
110134
  async function ensureSourceHost(root, hostUrl, opts = {}) {
110078
110135
  if (await isHostHealthy(hostUrl)) return { ok: true, hostUrl };
110079
110136
  if (!await sourceAvailable()) return { ok: false, reason: "source-unavailable" };
110137
+ try {
110138
+ await assertSourceVersionPinned();
110139
+ } catch {
110140
+ return { ok: false, reason: "version-mismatch" };
110141
+ }
110080
110142
  if (!isAnalyzed(root)) return { ok: false, reason: "not-analyzed" };
110081
110143
  const port = parseHostPort(hostUrl);
110082
110144
  if (port === null) return { ok: false, reason: "bad-url" };
110083
110145
  startHost(root, port);
110084
- if (await waitForHost(hostUrl, opts.timeoutMs ?? 2e4)) return { ok: true, hostUrl };
110146
+ if (await waitForHost(hostUrl, opts.timeoutMs ?? 2e4)) {
110147
+ reconcileSpawnedHostPid(root, port);
110148
+ return { ok: true, hostUrl };
110149
+ }
110085
110150
  removeSpawnedHostRecord(root);
110086
110151
  return { ok: false, reason: "unhealthy" };
110087
110152
  }
@@ -113530,16 +113595,28 @@ async function runStop(opts) {
113530
113595
  }
113531
113596
  }
113532
113597
  async function stopHost(root, hostUrl) {
113533
- const alive = await isHostHealthy(hostUrl);
113534
- if (!alive) {
113535
- console.log(import_picocolors36.default.dim(`Host ${hostUrl} is already stopped.`));
113536
- return 0;
113537
- }
113538
113598
  const port = extractPort(hostUrl);
113539
113599
  if (port === null) {
113540
113600
  console.error(import_picocolors36.default.red(`Cannot determine port from host URL: ${hostUrl}`));
113541
113601
  return 1;
113542
113602
  }
113603
+ const healthy = await isHostHealthy(hostUrl);
113604
+ if (!healthy) {
113605
+ const owned = readSpawnedHost(root);
113606
+ const wrapperAlive = owned !== null && await getProcessInfo(owned.pid) !== null;
113607
+ if (!wrapperAlive) {
113608
+ const res = await stopBackendServerPid(root, port);
113609
+ if (res === "failed") return 1;
113610
+ if (res !== "stopped") console.log(import_picocolors36.default.dim(`Host ${hostUrl} is already stopped.`));
113611
+ await cleanupSpawnedRecord(root);
113612
+ return 0;
113613
+ }
113614
+ console.log(
113615
+ import_picocolors36.default.dim(
113616
+ `Host ${hostUrl} is unreachable but pid ${owned.pid} is still running \u2014 terminating a stuck host.`
113617
+ )
113618
+ );
113619
+ }
113543
113620
  const spawned = readSpawnedHost(root);
113544
113621
  if (spawned === null) {
113545
113622
  console.error(
@@ -113572,21 +113649,18 @@ async function stopHost(root, hostUrl) {
113572
113649
  }
113573
113650
  const info2 = await getProcessInfo(spawned.pid);
113574
113651
  if (info2 === null) {
113575
- console.log(import_picocolors36.default.dim(`Process pid ${spawned.pid} is no longer running \u2014 already stopped.`));
113576
- try {
113577
- await unlinkAsync((0, import_node_path18.join)(root, HORUS_DIR, SPAWNED_HOST_FILE2));
113578
- } catch {
113652
+ const res = await stopBackendServerPid(root, port);
113653
+ if (res === "failed") return 1;
113654
+ if (res !== "stopped") {
113655
+ console.log(import_picocolors36.default.dim(`Process pid ${spawned.pid} is no longer running \u2014 already stopped.`));
113579
113656
  }
113657
+ await cleanupSpawnedRecord(root);
113580
113658
  return 0;
113581
113659
  }
113582
- const portStr = String(port);
113583
- const hostPortRe = new RegExp(
113584
- `(?:^|\\s)(?:\\S*/)?horus-source\\s+host\\s+--port(?:=|\\s+)${portStr}(?=\\s|$)`
113585
- );
113586
- if (!hostPortRe.test(info2.args)) {
113660
+ if (!hostArgvRegex(port).test(info2.args)) {
113587
113661
  console.error(
113588
113662
  import_picocolors36.default.red(
113589
- `Pid ${spawned.pid} args do not match "horus-source host --port ${portStr}". Got: "${info2.args.slice(0, 120)}". Aborting for safety.`
113663
+ `Pid ${spawned.pid} args do not match "horus-source host --port ${port}". Got: "${info2.args.slice(0, 120)}". Aborting for safety.`
113590
113664
  )
113591
113665
  );
113592
113666
  return 1;
@@ -113639,11 +113713,57 @@ async function stopHost(root, hostUrl) {
113639
113713
  `${import_picocolors36.default.green("\u2713")} Stopped source-intelligence host ` + import_picocolors36.default.dim(`(pid ${spawned.pid}, port ${port})`) + ` for ${root}`
113640
113714
  );
113641
113715
  }
113716
+ if (await stopBackendServerPid(root, port) === "failed") return 1;
113717
+ await cleanupSpawnedRecord(root);
113718
+ return 0;
113719
+ }
113720
+ function hostArgvRegex(port) {
113721
+ return new RegExp(
113722
+ `(?:^|\\s)(?:\\S*/)?horus-source\\s+host\\s+--port(?:=|\\s+)${String(port)}(?=\\s|$)`
113723
+ );
113724
+ }
113725
+ async function cleanupSpawnedRecord(root) {
113642
113726
  try {
113643
113727
  await unlinkAsync((0, import_node_path18.join)(root, HORUS_DIR, SPAWNED_HOST_FILE2));
113644
113728
  } catch {
113645
113729
  }
113646
- return 0;
113730
+ }
113731
+ async function stopBackendServerPid(root, port) {
113732
+ const rec = readSourceHostPid(root);
113733
+ if (!rec) return "none";
113734
+ if (Number.isFinite(rec.port) && rec.port !== port) return "none";
113735
+ if (rec.repoPath && rec.repoPath !== root) return "none";
113736
+ const info2 = await getProcessInfo(rec.pid);
113737
+ if (info2 === null) return "none";
113738
+ if (!hostArgvRegex(port).test(info2.args)) {
113739
+ console.error(
113740
+ import_picocolors36.default.red(
113741
+ `Backend host pid ${rec.pid} args do not match "horus-source host --port ${port}". Not signalling \u2014 possible stale source/host.json or PID reuse.`
113742
+ )
113743
+ );
113744
+ return "failed";
113745
+ }
113746
+ try {
113747
+ process.kill(rec.pid, "SIGTERM");
113748
+ } catch (err2) {
113749
+ if (err2.code === "ESRCH") return "stopped";
113750
+ console.error(import_picocolors36.default.red(`Failed to signal pid ${rec.pid}: ${err2.message}`));
113751
+ return "failed";
113752
+ }
113753
+ const deadline = Date.now() + STOP_WAIT_MS;
113754
+ while (Date.now() < deadline) {
113755
+ await sleep(STOP_POLL_MS);
113756
+ if (await getProcessInfo(rec.pid) === null) {
113757
+ console.log(
113758
+ `${import_picocolors36.default.green("\u2713")} Stopped source-intelligence host ` + import_picocolors36.default.dim(`(pid ${rec.pid}, port ${port})`) + ` for ${root}`
113759
+ );
113760
+ return "stopped";
113761
+ }
113762
+ }
113763
+ console.error(
113764
+ import_picocolors36.default.red(`Host pid ${rec.pid} did not exit within ${STOP_WAIT_MS / 1e3}s after SIGTERM.`)
113765
+ );
113766
+ return "failed";
113647
113767
  }
113648
113768
  async function stopAll() {
113649
113769
  const registry2 = readRegistry();
@@ -113657,8 +113777,10 @@ async function stopAll() {
113657
113777
  for (const [name2, entry2] of projects2) {
113658
113778
  const hostUrl = readSourceHostUrl(entry2.root);
113659
113779
  if (!hostUrl) continue;
113660
- const alive = await isHostHealthy(hostUrl);
113661
- if (!alive) continue;
113780
+ const healthy = await isHostHealthy(hostUrl);
113781
+ if (!healthy && readSpawnedHost(entry2.root) === null && readSourceHostPid(entry2.root) === null) {
113782
+ continue;
113783
+ }
113662
113784
  console.log(` Stopping ${import_picocolors36.default.bold(name2)} ${import_picocolors36.default.dim(`(${hostUrl})`)}`);
113663
113785
  const code = await stopHost(entry2.root, hostUrl);
113664
113786
  if (code === 0) stopped++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@merittdev/horus",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Local-first, source-aware production-incident investigation engine",
5
5
  "type": "module",
6
6
  "bin": {