@merittdev/horus 0.8.0 → 0.8.1

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 +123 -20
  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.1" : "dev";
65884
+ var PINNED_SOURCE_VERSION = "1.4.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`, {
@@ -97105,6 +97138,12 @@ async function runIndex(opts) {
97105
97138
  console.error(import_picocolors3.default.red("horus-source not found on PATH. Install it: pip install horus-source"));
97106
97139
  return 1;
97107
97140
  }
97141
+ try {
97142
+ await assertSourceVersionPinned();
97143
+ } catch (err2) {
97144
+ console.error(import_picocolors3.default.red(` ${err2.message}`));
97145
+ return 1;
97146
+ }
97108
97147
  if (!isAnalyzed(root)) {
97109
97148
  console.log(import_picocolors3.default.dim(" analyzing with source-intelligence backend (first time \u2014 this can take a while)\u2026"));
97110
97149
  try {
@@ -110066,6 +110105,8 @@ function ensureHostReasonHint(reason) {
110066
110105
  switch (reason) {
110067
110106
  case "source-unavailable":
110068
110107
  return "horus-source is not installed \u2014 install it: pip install horus-source";
110108
+ case "version-mismatch":
110109
+ 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
110110
  case "not-analyzed":
110070
110111
  return "this repo has not been indexed yet \u2014 run: horus index";
110071
110112
  case "bad-url":
@@ -110077,6 +110118,11 @@ function ensureHostReasonHint(reason) {
110077
110118
  async function ensureSourceHost(root, hostUrl, opts = {}) {
110078
110119
  if (await isHostHealthy(hostUrl)) return { ok: true, hostUrl };
110079
110120
  if (!await sourceAvailable()) return { ok: false, reason: "source-unavailable" };
110121
+ try {
110122
+ await assertSourceVersionPinned();
110123
+ } catch {
110124
+ return { ok: false, reason: "version-mismatch" };
110125
+ }
110080
110126
  if (!isAnalyzed(root)) return { ok: false, reason: "not-analyzed" };
110081
110127
  const port = parseHostPort(hostUrl);
110082
110128
  if (port === null) return { ok: false, reason: "bad-url" };
@@ -113530,16 +113576,28 @@ async function runStop(opts) {
113530
113576
  }
113531
113577
  }
113532
113578
  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
113579
  const port = extractPort(hostUrl);
113539
113580
  if (port === null) {
113540
113581
  console.error(import_picocolors36.default.red(`Cannot determine port from host URL: ${hostUrl}`));
113541
113582
  return 1;
113542
113583
  }
113584
+ const healthy = await isHostHealthy(hostUrl);
113585
+ if (!healthy) {
113586
+ const owned = readSpawnedHost(root);
113587
+ const wrapperAlive = owned !== null && await getProcessInfo(owned.pid) !== null;
113588
+ if (!wrapperAlive) {
113589
+ const res = await stopBackendServerPid(root, port);
113590
+ if (res === "failed") return 1;
113591
+ if (res !== "stopped") console.log(import_picocolors36.default.dim(`Host ${hostUrl} is already stopped.`));
113592
+ await cleanupSpawnedRecord(root);
113593
+ return 0;
113594
+ }
113595
+ console.log(
113596
+ import_picocolors36.default.dim(
113597
+ `Host ${hostUrl} is unreachable but pid ${owned.pid} is still running \u2014 terminating a stuck host.`
113598
+ )
113599
+ );
113600
+ }
113543
113601
  const spawned = readSpawnedHost(root);
113544
113602
  if (spawned === null) {
113545
113603
  console.error(
@@ -113572,21 +113630,18 @@ async function stopHost(root, hostUrl) {
113572
113630
  }
113573
113631
  const info2 = await getProcessInfo(spawned.pid);
113574
113632
  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 {
113633
+ const res = await stopBackendServerPid(root, port);
113634
+ if (res === "failed") return 1;
113635
+ if (res !== "stopped") {
113636
+ console.log(import_picocolors36.default.dim(`Process pid ${spawned.pid} is no longer running \u2014 already stopped.`));
113579
113637
  }
113638
+ await cleanupSpawnedRecord(root);
113580
113639
  return 0;
113581
113640
  }
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)) {
113641
+ if (!hostArgvRegex(port).test(info2.args)) {
113587
113642
  console.error(
113588
113643
  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.`
113644
+ `Pid ${spawned.pid} args do not match "horus-source host --port ${port}". Got: "${info2.args.slice(0, 120)}". Aborting for safety.`
113590
113645
  )
113591
113646
  );
113592
113647
  return 1;
@@ -113639,11 +113694,57 @@ async function stopHost(root, hostUrl) {
113639
113694
  `${import_picocolors36.default.green("\u2713")} Stopped source-intelligence host ` + import_picocolors36.default.dim(`(pid ${spawned.pid}, port ${port})`) + ` for ${root}`
113640
113695
  );
113641
113696
  }
113697
+ if (await stopBackendServerPid(root, port) === "failed") return 1;
113698
+ await cleanupSpawnedRecord(root);
113699
+ return 0;
113700
+ }
113701
+ function hostArgvRegex(port) {
113702
+ return new RegExp(
113703
+ `(?:^|\\s)(?:\\S*/)?horus-source\\s+host\\s+--port(?:=|\\s+)${String(port)}(?=\\s|$)`
113704
+ );
113705
+ }
113706
+ async function cleanupSpawnedRecord(root) {
113642
113707
  try {
113643
113708
  await unlinkAsync((0, import_node_path18.join)(root, HORUS_DIR, SPAWNED_HOST_FILE2));
113644
113709
  } catch {
113645
113710
  }
113646
- return 0;
113711
+ }
113712
+ async function stopBackendServerPid(root, port) {
113713
+ const rec = readSourceHostPid(root);
113714
+ if (!rec) return "none";
113715
+ if (Number.isFinite(rec.port) && rec.port !== port) return "none";
113716
+ if (rec.repoPath && rec.repoPath !== root) return "none";
113717
+ const info2 = await getProcessInfo(rec.pid);
113718
+ if (info2 === null) return "none";
113719
+ if (!hostArgvRegex(port).test(info2.args)) {
113720
+ console.error(
113721
+ import_picocolors36.default.red(
113722
+ `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.`
113723
+ )
113724
+ );
113725
+ return "failed";
113726
+ }
113727
+ try {
113728
+ process.kill(rec.pid, "SIGTERM");
113729
+ } catch (err2) {
113730
+ if (err2.code === "ESRCH") return "stopped";
113731
+ console.error(import_picocolors36.default.red(`Failed to signal pid ${rec.pid}: ${err2.message}`));
113732
+ return "failed";
113733
+ }
113734
+ const deadline = Date.now() + STOP_WAIT_MS;
113735
+ while (Date.now() < deadline) {
113736
+ await sleep(STOP_POLL_MS);
113737
+ if (await getProcessInfo(rec.pid) === null) {
113738
+ console.log(
113739
+ `${import_picocolors36.default.green("\u2713")} Stopped source-intelligence host ` + import_picocolors36.default.dim(`(pid ${rec.pid}, port ${port})`) + ` for ${root}`
113740
+ );
113741
+ return "stopped";
113742
+ }
113743
+ }
113744
+ console.error(
113745
+ import_picocolors36.default.red(`Host pid ${rec.pid} did not exit within ${STOP_WAIT_MS / 1e3}s after SIGTERM.`)
113746
+ );
113747
+ return "failed";
113647
113748
  }
113648
113749
  async function stopAll() {
113649
113750
  const registry2 = readRegistry();
@@ -113657,8 +113758,10 @@ async function stopAll() {
113657
113758
  for (const [name2, entry2] of projects2) {
113658
113759
  const hostUrl = readSourceHostUrl(entry2.root);
113659
113760
  if (!hostUrl) continue;
113660
- const alive = await isHostHealthy(hostUrl);
113661
- if (!alive) continue;
113761
+ const healthy = await isHostHealthy(hostUrl);
113762
+ if (!healthy && readSpawnedHost(entry2.root) === null && readSourceHostPid(entry2.root) === null) {
113763
+ continue;
113764
+ }
113662
113765
  console.log(` Stopping ${import_picocolors36.default.bold(name2)} ${import_picocolors36.default.dim(`(${hostUrl})`)}`);
113663
113766
  const code = await stopHost(entry2.root, hostUrl);
113664
113767
  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.1",
4
4
  "description": "Local-first, source-aware production-incident investigation engine",
5
5
  "type": "module",
6
6
  "bin": {