@merittdev/horus 0.1.10 → 0.1.11

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 +574 -67
  2. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -49547,7 +49547,7 @@ var require_Redis = __commonJS({
49547
49547
  var lodash_1 = require_lodash();
49548
49548
  var Deque = require_denque();
49549
49549
  var debug = (0, utils_1.Debug)("redis");
49550
- var Redis2 = class _Redis extends Commander_1.default {
49550
+ var Redis3 = class _Redis extends Commander_1.default {
49551
49551
  constructor(arg1, arg2, arg3) {
49552
49552
  super();
49553
49553
  this.status = "wait";
@@ -50212,12 +50212,12 @@ var require_Redis = __commonJS({
50212
50212
  }).catch(lodash_1.noop);
50213
50213
  }
50214
50214
  };
50215
- Redis2.Cluster = cluster_1.default;
50216
- Redis2.Command = Command_1.default;
50217
- Redis2.defaultOptions = RedisOptions_1.DEFAULT_REDIS_OPTIONS;
50218
- (0, applyMixin_1.default)(Redis2, events_1.EventEmitter);
50219
- (0, transaction_1.addTransactionSupport)(Redis2.prototype);
50220
- exports2.default = Redis2;
50215
+ Redis3.Cluster = cluster_1.default;
50216
+ Redis3.Command = Command_1.default;
50217
+ Redis3.defaultOptions = RedisOptions_1.DEFAULT_REDIS_OPTIONS;
50218
+ (0, applyMixin_1.default)(Redis3, events_1.EventEmitter);
50219
+ (0, transaction_1.addTransactionSupport)(Redis3.prototype);
50220
+ exports2.default = Redis3;
50221
50221
  }
50222
50222
  });
50223
50223
 
@@ -50314,7 +50314,7 @@ init_cjs_shims();
50314
50314
 
50315
50315
  // ../../packages/core/src/version.ts
50316
50316
  init_cjs_shims();
50317
- var HORUS_VERSION = true ? "0.1.10" : "dev";
50317
+ var HORUS_VERSION = true ? "0.1.11" : "dev";
50318
50318
  var PINNED_AXON_VERSION = "1.0.7";
50319
50319
  var PINNED_SOURCE_VERSION = PINNED_AXON_VERSION;
50320
50320
 
@@ -54541,6 +54541,37 @@ var repositorySchema = external_exports.object({
54541
54541
  */
54542
54542
  axon: external_exports.object({ hostUrl: external_exports.string().url() }).optional()
54543
54543
  });
54544
+ var REDIS_ROLES = [
54545
+ "cache",
54546
+ "state",
54547
+ "locks",
54548
+ "rate-limit",
54549
+ "session",
54550
+ "dedupe",
54551
+ "bullmq",
54552
+ "queues"
54553
+ ];
54554
+ var redisRoleSchema = external_exports.enum(REDIS_ROLES);
54555
+ var REDIS_QUEUE_ROLES = ["bullmq", "queues"];
54556
+ function redisDbFromUrl(url) {
54557
+ if (!url) return 0;
54558
+ try {
54559
+ const seg = new URL(url).pathname.replace(/^\//, "");
54560
+ const n = Number.parseInt(seg, 10);
54561
+ return Number.isInteger(n) && n >= 0 ? n : 0;
54562
+ } catch {
54563
+ return 0;
54564
+ }
54565
+ }
54566
+ function redisUrlForDb(url, db) {
54567
+ try {
54568
+ const u = new URL(url);
54569
+ u.pathname = `/${db}`;
54570
+ return u.toString();
54571
+ } catch {
54572
+ return url;
54573
+ }
54574
+ }
54544
54575
  var connectorsSchema = external_exports.object({
54545
54576
  elasticsearch: external_exports.object({
54546
54577
  indexPattern: external_exports.string().optional(),
@@ -54619,7 +54650,33 @@ var connectorsSchema = external_exports.object({
54619
54650
  /** Direct URL value (takes priority over urlEnv). */
54620
54651
  url: external_exports.string().optional(),
54621
54652
  /** Name of the env var holding the Redis URL. Defaults to "REDIS_URL". */
54622
- urlEnv: external_exports.string().optional()
54653
+ urlEnv: external_exports.string().optional(),
54654
+ /**
54655
+ * Logical databases on the same Redis server (HOR-201). Redis is a general
54656
+ * runtime-evidence connector — a single server commonly holds BullMQ queues in
54657
+ * one DB and cache/locks/rate-limit/session keys in others. Declaring them here
54658
+ * (with roles) lets `queues --live` target the queue DB and lets investigation
54659
+ * collectors target cache/state/locks DBs. When omitted, the DB index in `url`
54660
+ * (default 0) is treated as a single configured database (backward compatible).
54661
+ */
54662
+ databases: external_exports.array(
54663
+ external_exports.object({
54664
+ /** Logical DB index on the server (0–15 by default Redis config). */
54665
+ db: external_exports.number().int().min(0).max(15),
54666
+ /** Friendly name for display (e.g. "cache", "queues"). */
54667
+ name: external_exports.string().optional(),
54668
+ /** What this DB holds — drives which collectors/commands use it. */
54669
+ roles: external_exports.array(redisRoleSchema).default([]),
54670
+ /** BullMQ settings when this DB has the bullmq/queues role. */
54671
+ bullmq: external_exports.object({ prefix: external_exports.string().default("bull") }).optional(),
54672
+ /** Sampled-scan settings for cache/state/locks DBs. */
54673
+ scan: external_exports.object({
54674
+ enabled: external_exports.boolean().default(true),
54675
+ sampleLimit: external_exports.number().int().positive().default(500),
54676
+ patterns: external_exports.array(external_exports.string()).default([])
54677
+ }).optional()
54678
+ })
54679
+ ).optional()
54623
54680
  }).optional()
54624
54681
  }).default({});
54625
54682
  var environmentSchema = external_exports.object({
@@ -54762,8 +54819,28 @@ function resolveEnvironment(config, opts) {
54762
54819
  }
54763
54820
  if (c.redis !== void 0) {
54764
54821
  const r = c.redis;
54822
+ const url = r.url ?? process.env[r.urlEnv ?? "REDIS_URL"];
54823
+ let databases;
54824
+ if (r.databases !== void 0 && r.databases.length > 0) {
54825
+ databases = r.databases.map((d) => ({
54826
+ db: d.db,
54827
+ ...d.name !== void 0 ? { name: d.name } : {},
54828
+ roles: d.roles,
54829
+ bullmqPrefix: d.bullmq?.prefix ?? "bull",
54830
+ ...d.scan !== void 0 ? {
54831
+ scan: {
54832
+ enabled: d.scan.enabled,
54833
+ sampleLimit: d.scan.sampleLimit,
54834
+ patterns: d.scan.patterns
54835
+ }
54836
+ } : {}
54837
+ }));
54838
+ } else {
54839
+ databases = [{ db: redisDbFromUrl(url), roles: [], bullmqPrefix: "bull" }];
54840
+ }
54765
54841
  resolved.redis = {
54766
- url: r.url ?? process.env[r.urlEnv ?? "REDIS_URL"]
54842
+ ...url !== void 0 ? { url } : {},
54843
+ databases
54767
54844
  };
54768
54845
  }
54769
54846
  return {
@@ -57339,6 +57416,174 @@ var BullMQRuntimeProvider = class {
57339
57416
  }
57340
57417
  };
57341
57418
 
57419
+ // ../../packages/connectors/src/redis/state-provider.ts
57420
+ init_cjs_shims();
57421
+
57422
+ // ../../packages/connectors/src/redis/scan-client.ts
57423
+ init_cjs_shims();
57424
+ var import_ioredis2 = __toESM(require_built3(), 1);
57425
+ var SAMPLE_DEFAULT = 500;
57426
+ var SCAN_BATCH = 200;
57427
+ var RedisScanClient = class {
57428
+ redis;
57429
+ db;
57430
+ constructor(opts) {
57431
+ this.db = opts.db ?? 0;
57432
+ const redisOpts = {
57433
+ db: this.db,
57434
+ lazyConnect: true,
57435
+ enableReadyCheck: false,
57436
+ maxRetriesPerRequest: 1,
57437
+ connectTimeout: 5e3
57438
+ };
57439
+ this.redis = new import_ioredis2.Redis(opts.url, redisOpts);
57440
+ this.redis.on("error", () => {
57441
+ });
57442
+ }
57443
+ /** Ping the server (also exercises AUTH). */
57444
+ async health() {
57445
+ try {
57446
+ await this.redis.ping();
57447
+ return { ok: true, detail: `Redis reachable (db ${this.db})` };
57448
+ } catch (err) {
57449
+ return { ok: false, detail: err.message };
57450
+ }
57451
+ }
57452
+ /** Total number of keys in this DB (DBSIZE). */
57453
+ async dbSize() {
57454
+ return this.redis.dbsize();
57455
+ }
57456
+ /**
57457
+ * Sample up to `limit` keys and group them by their leading segment (before the
57458
+ * first ':'), returning the most common prefixes. Used to characterise a cache/
57459
+ * state DB without reading values. Optional `patterns` restrict the scan.
57460
+ */
57461
+ async samplePrefixes(limit = SAMPLE_DEFAULT, patterns) {
57462
+ const counts = /* @__PURE__ */ new Map();
57463
+ let sampled = 0;
57464
+ const scanPatterns = patterns && patterns.length > 0 ? patterns : ["*"];
57465
+ for (const pattern of scanPatterns) {
57466
+ let cursor = "0";
57467
+ do {
57468
+ const [next, batch] = await this.redis.scan(
57469
+ cursor,
57470
+ "MATCH",
57471
+ pattern,
57472
+ "COUNT",
57473
+ SCAN_BATCH
57474
+ );
57475
+ cursor = next;
57476
+ for (const key of batch) {
57477
+ const prefix = key.includes(":") ? key.slice(0, key.indexOf(":")) : key;
57478
+ counts.set(prefix, (counts.get(prefix) ?? 0) + 1);
57479
+ if (++sampled >= limit) break;
57480
+ }
57481
+ } while (cursor !== "0" && sampled < limit);
57482
+ if (sampled >= limit) break;
57483
+ }
57484
+ return [...counts.entries()].map(([prefix, count]) => ({ prefix, count })).sort((a, b2) => b2.count - a.count);
57485
+ }
57486
+ /**
57487
+ * Detect BullMQ queue names in this DB by scanning `{prefix}:*:meta`. `:meta` exists
57488
+ * for every instantiated queue (even idle ones), so this finds all queues, not just
57489
+ * those with pending jobs. Returns at most `limit` names.
57490
+ */
57491
+ async detectBullmqQueues(prefix = "bull", limit = 200) {
57492
+ const pattern = `${prefix}:*:meta`;
57493
+ const suffixLen = ":meta".length;
57494
+ const prefixLen = prefix.length + 1;
57495
+ const names = [];
57496
+ let cursor = "0";
57497
+ do {
57498
+ const [next, batch] = await this.redis.scan(cursor, "MATCH", pattern, "COUNT", SCAN_BATCH);
57499
+ cursor = next;
57500
+ for (const key of batch) {
57501
+ names.push(key.slice(prefixLen, key.length - suffixLen));
57502
+ if (names.length >= limit) return names;
57503
+ }
57504
+ } while (cursor !== "0" && names.length < limit);
57505
+ return names;
57506
+ }
57507
+ async close() {
57508
+ this.redis.disconnect();
57509
+ }
57510
+ };
57511
+
57512
+ // ../../packages/connectors/src/redis/state-provider.ts
57513
+ var LOCK_RE = /lock/i;
57514
+ var RATELIMIT_RE = /rate.?limit|throttle/i;
57515
+ var RedisStateRuntimeProvider = class {
57516
+ constructor(dbs, mkClient = (d) => new RedisScanClient({ url: d.url, db: d.db })) {
57517
+ this.dbs = dbs;
57518
+ this.mkClient = mkClient;
57519
+ }
57520
+ dbs;
57521
+ mkClient;
57522
+ async analyzeRedisState() {
57523
+ const collectedAt = (/* @__PURE__ */ new Date()).toISOString();
57524
+ const databases = [];
57525
+ const signals = [];
57526
+ for (const d of this.dbs) {
57527
+ const client = this.mkClient(d);
57528
+ try {
57529
+ const health = await client.health();
57530
+ if (!health.ok) continue;
57531
+ const keyCount = await client.dbSize();
57532
+ const prefixes = await client.samplePrefixes(d.scan?.sampleLimit ?? 500, d.scan?.patterns);
57533
+ databases.push({
57534
+ db: d.db,
57535
+ ...d.name !== void 0 ? { name: d.name } : {},
57536
+ roles: d.roles,
57537
+ keyCount,
57538
+ prefixes
57539
+ });
57540
+ if (keyCount === 0) continue;
57541
+ const roleLabel = d.roles.length > 0 ? d.roles.join("/") : "state";
57542
+ const topPrefixes = prefixes.slice(0, 10);
57543
+ const topText = topPrefixes.map((p) => p.prefix).join(" ");
57544
+ signals.push({
57545
+ db: d.db,
57546
+ title: `Redis DB ${d.db} (${roleLabel}): ${keyCount} key(s), top prefixes ${topPrefixes.slice(0, 4).map((p) => `${p.prefix}:*`).join(", ")}`,
57547
+ payload: { db: d.db, roles: d.roles, keyCount, topPrefixes },
57548
+ relevance: 0.4,
57549
+ matchText: `${roleLabel} ${topText}`.toLowerCase()
57550
+ });
57551
+ const lockPrefixes = prefixes.filter((p) => LOCK_RE.test(p.prefix));
57552
+ if (d.roles.includes("locks") || lockPrefixes.length > 0) {
57553
+ const lockKeys = lockPrefixes.reduce((n, p) => n + p.count, 0);
57554
+ if (lockKeys > 0) {
57555
+ signals.push({
57556
+ db: d.db,
57557
+ title: `Redis DB ${d.db}: ${lockKeys} lock key(s) present (${lockPrefixes.map((p) => `${p.prefix}:*`).join(", ")})`,
57558
+ payload: { db: d.db, lockKeys, lockPrefixes },
57559
+ relevance: 0.6,
57560
+ matchText: `lock locks ${lockPrefixes.map((p) => p.prefix).join(" ")}`.toLowerCase()
57561
+ });
57562
+ }
57563
+ }
57564
+ const rlPrefixes = prefixes.filter((p) => RATELIMIT_RE.test(p.prefix));
57565
+ if (d.roles.includes("rate-limit") || rlPrefixes.length > 0) {
57566
+ const rlKeys = rlPrefixes.reduce((n, p) => n + p.count, 0);
57567
+ if (rlKeys > 0) {
57568
+ signals.push({
57569
+ db: d.db,
57570
+ title: `Redis DB ${d.db}: ${rlKeys} rate-limit key(s) present`,
57571
+ payload: { db: d.db, rateLimitKeys: rlKeys, prefixes: rlPrefixes },
57572
+ relevance: 0.6,
57573
+ matchText: `rate limit throttle ${rlPrefixes.map((p) => p.prefix).join(" ")}`.toLowerCase()
57574
+ });
57575
+ }
57576
+ }
57577
+ } finally {
57578
+ await client.close();
57579
+ }
57580
+ }
57581
+ return { collectedAt, databases, signals };
57582
+ }
57583
+ async close() {
57584
+ }
57585
+ };
57586
+
57342
57587
  // ../../packages/connectors/src/factory.ts
57343
57588
  function codeForEnv(renv) {
57344
57589
  const repo = renv.repositories[0];
@@ -57380,10 +57625,43 @@ function metricsForEnv(renv) {
57380
57625
  { defaultStep: 60, dashboardUids: g.dashboards }
57381
57626
  );
57382
57627
  }
57628
+ function dbHasRole(db, roles) {
57629
+ return db.roles.some((r) => roles.includes(r));
57630
+ }
57631
+ function queueDatabaseForEnv(renv) {
57632
+ const dbs = renv.connectors.redis?.databases;
57633
+ if (!dbs || dbs.length === 0) return null;
57634
+ return dbs.find((d) => dbHasRole(d, REDIS_QUEUE_ROLES)) ?? (dbs.length === 1 ? dbs[0] : null);
57635
+ }
57636
+ var STATE_ROLES = ["cache", "state", "locks", "rate-limit", "session", "dedupe"];
57637
+ function stateDatabasesForEnv(renv) {
57638
+ const dbs = renv.connectors.redis?.databases ?? [];
57639
+ return dbs.filter((d) => dbHasRole(d, STATE_ROLES));
57640
+ }
57641
+ function redisStateForEnv(renv) {
57642
+ const r = renv.connectors.redis;
57643
+ if (!r?.url) return null;
57644
+ const stateDbs = stateDatabasesForEnv(renv);
57645
+ if (stateDbs.length === 0) return null;
57646
+ const baseUrl = r.url;
57647
+ return new RedisStateRuntimeProvider(
57648
+ stateDbs.map((d) => ({
57649
+ db: d.db,
57650
+ ...d.name !== void 0 ? { name: d.name } : {},
57651
+ roles: d.roles,
57652
+ url: redisUrlForDb(baseUrl, d.db),
57653
+ ...d.scan !== void 0 ? { scan: { sampleLimit: d.scan.sampleLimit, patterns: d.scan.patterns } } : {}
57654
+ }))
57655
+ );
57656
+ }
57383
57657
  function queueForEnv(renv) {
57384
57658
  const r = renv.connectors.redis;
57385
57659
  if (!r?.url) return null;
57386
- return new BullMQRuntimeProvider(new BullMQRedisClient({ url: r.url }));
57660
+ const queueDb = queueDatabaseForEnv(renv);
57661
+ if (!queueDb) return null;
57662
+ return new BullMQRuntimeProvider(
57663
+ new BullMQRedisClient({ url: redisUrlForDb(r.url, queueDb.db), prefix: queueDb.bullmqPrefix })
57664
+ );
57387
57665
  }
57388
57666
  function mongoForEnv(renv) {
57389
57667
  const m = renv.connectors.mongodb;
@@ -57553,6 +57831,130 @@ init_cjs_shims();
57553
57831
  // ../../packages/connectors/src/bullmq/index.ts
57554
57832
  init_cjs_shims();
57555
57833
 
57834
+ // ../../packages/connectors/src/redis/index.ts
57835
+ init_cjs_shims();
57836
+
57837
+ // ../../packages/connectors/src/redis/discovery.ts
57838
+ init_cjs_shims();
57839
+ function inferStateRoles(prefixes) {
57840
+ const roles = /* @__PURE__ */ new Set(["cache", "state"]);
57841
+ for (const { prefix } of prefixes) {
57842
+ const p = prefix.toLowerCase();
57843
+ if (/lock/.test(p)) roles.add("locks");
57844
+ if (/rate.?limit|throttle/.test(p)) roles.add("rate-limit");
57845
+ if (/session|sess/.test(p)) roles.add("session");
57846
+ if (/dedup/.test(p)) roles.add("dedupe");
57847
+ }
57848
+ return [...roles];
57849
+ }
57850
+ async function probeRedisDatabases(baseUrl, opts = {}) {
57851
+ const dbRange = opts.dbRange ?? Array.from({ length: 16 }, (_, i) => i);
57852
+ const sampleLimit = opts.sampleLimit ?? 500;
57853
+ const bullmqPrefix = opts.bullmqPrefix ?? "bull";
57854
+ const probes = [];
57855
+ for (const db of dbRange) {
57856
+ const client = new RedisScanClient({ url: baseUrl, db });
57857
+ try {
57858
+ const health = await client.health();
57859
+ if (!health.ok) {
57860
+ probes.push({
57861
+ db,
57862
+ reachable: false,
57863
+ detail: health.detail,
57864
+ keyCount: 0,
57865
+ prefixes: [],
57866
+ bullmqQueues: [],
57867
+ suggestedRoles: []
57868
+ });
57869
+ break;
57870
+ }
57871
+ const keyCount = await client.dbSize();
57872
+ if (keyCount === 0) {
57873
+ probes.push({ db, reachable: true, keyCount: 0, prefixes: [], bullmqQueues: [], suggestedRoles: [] });
57874
+ continue;
57875
+ }
57876
+ const bullmqQueues = await client.detectBullmqQueues(bullmqPrefix);
57877
+ if (bullmqQueues.length > 0) {
57878
+ probes.push({
57879
+ db,
57880
+ reachable: true,
57881
+ keyCount,
57882
+ prefixes: [],
57883
+ bullmqQueues,
57884
+ bullmqPrefix,
57885
+ suggestedRoles: ["bullmq", "queues"]
57886
+ });
57887
+ } else {
57888
+ const prefixes = await client.samplePrefixes(sampleLimit);
57889
+ probes.push({
57890
+ db,
57891
+ reachable: true,
57892
+ keyCount,
57893
+ prefixes,
57894
+ bullmqQueues: [],
57895
+ suggestedRoles: inferStateRoles(prefixes)
57896
+ });
57897
+ }
57898
+ } finally {
57899
+ await client.close();
57900
+ }
57901
+ }
57902
+ return probes;
57903
+ }
57904
+
57905
+ // ../../packages/connectors/src/redis/status.ts
57906
+ init_cjs_shims();
57907
+ async function redisServerStatus(renv) {
57908
+ const r = renv.connectors.redis;
57909
+ if (!r?.url) return null;
57910
+ const url = r.url;
57911
+ let reachable = false;
57912
+ let authFailed = false;
57913
+ const databases = [];
57914
+ for (const d of r.databases) {
57915
+ const client = new RedisScanClient({ url: redisUrlForDb(url, d.db), db: d.db });
57916
+ try {
57917
+ const health = await client.health();
57918
+ const isQueue = d.roles.some((role) => REDIS_QUEUE_ROLES.includes(role));
57919
+ if (!health.ok) {
57920
+ if (/WRONGPASS|NOAUTH|invalid password/i.test(health.detail)) authFailed = true;
57921
+ databases.push({
57922
+ db: d.db,
57923
+ ...d.name !== void 0 ? { name: d.name } : {},
57924
+ roles: d.roles,
57925
+ reachable: false,
57926
+ detail: health.detail
57927
+ });
57928
+ continue;
57929
+ }
57930
+ reachable = true;
57931
+ if (isQueue) {
57932
+ const queues = await client.detectBullmqQueues(d.bullmqPrefix);
57933
+ databases.push({
57934
+ db: d.db,
57935
+ ...d.name !== void 0 ? { name: d.name } : {},
57936
+ roles: d.roles,
57937
+ reachable: true,
57938
+ queueCount: queues.length,
57939
+ bullmqPrefix: d.bullmqPrefix
57940
+ });
57941
+ } else {
57942
+ const keyCount = await client.dbSize();
57943
+ databases.push({
57944
+ db: d.db,
57945
+ ...d.name !== void 0 ? { name: d.name } : {},
57946
+ roles: d.roles,
57947
+ reachable: true,
57948
+ keyCount
57949
+ });
57950
+ }
57951
+ } finally {
57952
+ await client.close();
57953
+ }
57954
+ }
57955
+ return { reachable, authFailed, databases };
57956
+ }
57957
+
57556
57958
  // ../../packages/db/src/index.ts
57557
57959
  init_cjs_shims();
57558
57960
 
@@ -66802,37 +67204,31 @@ async function checkEnv(renv, deps) {
66802
67204
  }
66803
67205
  const redisCfg = renv.connectors.redis;
66804
67206
  if (redisCfg?.url) {
66805
- const safeUrl = redactRedisUrl(redisCfg.url);
66806
- const queueProvider = (deps?.queueFactory ?? queueForEnv)(renv);
66807
- if (queueProvider) {
66808
- try {
66809
- const h = await queueProvider.health();
66810
- if (h.ok) {
66811
- let queueInfo = "";
66812
- try {
66813
- const discovered = await queueProvider.discoverQueues();
66814
- queueInfo = ` \xB7 ${discovered.length} queue(s)`;
66815
- } catch {
66816
- }
66817
- console.log(
66818
- ` ${mark(true)} ${import_picocolors.default.bold("Redis")} ${import_picocolors.default.dim(`reachable \xB7 ${safeUrl}${queueInfo}`)}`
66819
- );
67207
+ const server = redisServerLabel(redisCfg.url);
67208
+ const status = await (deps?.redisStatus ?? redisServerStatus)(renv);
67209
+ if (!status) {
67210
+ console.log(` ${mark("pending")} ${import_picocolors.default.bold("Redis")} ${import_picocolors.default.dim(`configured \xB7 ${server}`)}`);
67211
+ } else if (!status.reachable) {
67212
+ const state = status.authFailed ? "auth failed" : "unreachable";
67213
+ console.log(` ${mark(false)} ${import_picocolors.default.bold("Redis")} ${import_picocolors.default.dim(`${state} \xB7 ${server}`)}`);
67214
+ allOk = false;
67215
+ } else {
67216
+ console.log(` ${mark(true)} ${import_picocolors.default.bold("Redis")} ${import_picocolors.default.dim(`reachable \xB7 ${server}`)}`);
67217
+ for (const d of status.databases) {
67218
+ const roleLabel = d.roles.length > 0 ? d.roles.join("/") : "unrolled";
67219
+ const name = d.name ? ` ${d.name}` : "";
67220
+ let detail;
67221
+ if (!d.reachable) {
67222
+ detail = `${/WRONGPASS|NOAUTH/i.test(d.detail ?? "") ? "auth failed" : "unreachable"}`;
67223
+ } else if (d.queueCount !== void 0) {
67224
+ detail = `${d.queueCount} queue(s), prefix ${d.bullmqPrefix}`;
66820
67225
  } else {
66821
- const authFailed = /WRONGPASS|NOAUTH|invalid password/i.test(h.detail);
66822
- const state = authFailed ? "auth failed" : "unreachable";
66823
- console.log(
66824
- ` ${mark(false)} ${import_picocolors.default.bold("Redis")} ${import_picocolors.default.dim(`${state} \xB7 ${safeUrl} \xB7 ${h.detail}`)}`
66825
- );
66826
- allOk = false;
67226
+ detail = `${d.keyCount ?? 0} key(s)`;
66827
67227
  }
66828
- } finally {
66829
- await queueProvider.close().catch(() => {
66830
- });
67228
+ console.log(
67229
+ ` ${mark(d.reachable)} ${import_picocolors.default.bold(`DB ${d.db}`)}${name} ${import_picocolors.default.dim(`${roleLabel} \xB7 ${detail}`)}`
67230
+ );
66831
67231
  }
66832
- } else {
66833
- console.log(
66834
- ` ${mark("pending")} ${import_picocolors.default.bold("Redis")} ${import_picocolors.default.dim(`configured \xB7 ${safeUrl}`)}`
66835
- );
66836
67232
  }
66837
67233
  } else {
66838
67234
  console.log(
@@ -66856,6 +67252,14 @@ function redactRedisUrl(raw) {
66856
67252
  return raw.replace(/\/\/:?[^@]*@/, "//:***@");
66857
67253
  }
66858
67254
  }
67255
+ function redisServerLabel(raw) {
67256
+ try {
67257
+ const u = new URL(raw);
67258
+ return `${u.hostname}:${u.port || "6379"}`;
67259
+ } catch {
67260
+ return redactRedisUrl(raw);
67261
+ }
67262
+ }
66859
67263
  async function runStatus(configPath, opts) {
66860
67264
  console.log(import_picocolors.default.bold(`
66861
67265
  Horus ${HORUS_VERSION}`));
@@ -66905,7 +67309,7 @@ Horus ${HORUS_VERSION}`));
66905
67309
  console.error(import_picocolors.default.red(err.message));
66906
67310
  return 1;
66907
67311
  }
66908
- const ok = await checkEnv(renv, { mongoFactory: opts?._mongoFactory, queueFactory: opts?._queueFactory });
67312
+ const ok = await checkEnv(renv, { mongoFactory: opts?._mongoFactory, redisStatus: opts?._redisStatus });
66909
67313
  return ok ? 0 : 1;
66910
67314
  }
66911
67315
  let allHealthy = true;
@@ -66918,7 +67322,7 @@ Horus ${HORUS_VERSION}`));
66918
67322
  allHealthy = false;
66919
67323
  continue;
66920
67324
  }
66921
- const ok = await checkEnv(renv, { mongoFactory: opts?._mongoFactory, queueFactory: opts?._queueFactory });
67325
+ const ok = await checkEnv(renv, { mongoFactory: opts?._mongoFactory, redisStatus: opts?._redisStatus });
66922
67326
  if (!ok) allHealthy = false;
66923
67327
  }
66924
67328
  return checks.some((c) => c.ok === false && c.fatal) ? 1 : 0;
@@ -69783,6 +70187,22 @@ async function investigate(input, deps) {
69783
70187
  stateAnalysis = null;
69784
70188
  }
69785
70189
  }
70190
+ let redisStateAnalysis = null;
70191
+ const redisStateEvIds = [];
70192
+ if (deps.redisState) {
70193
+ try {
70194
+ redisStateAnalysis = await deps.redisState.analyzeRedisState();
70195
+ const redisTerms = [...new Set(tokenize(hint))];
70196
+ for (const s of redisStateAnalysis.signals) {
70197
+ const matched = redisTerms.some((t) => t.length > 2 && s.matchText.includes(t));
70198
+ const relevance = matched ? Math.min(1, s.relevance + 0.3) : s.relevance;
70199
+ const ev = mkEv("redis-key", s.title, s.payload, {}, redisStateAnalysis.collectedAt, relevance);
70200
+ redisStateEvIds.push(ev.id);
70201
+ }
70202
+ } catch {
70203
+ redisStateAnalysis = null;
70204
+ }
70205
+ }
69786
70206
  let queueRuntimeState = null;
69787
70207
  const queueRuntimeEvIds = [];
69788
70208
  const queueRuntimeEvIdsByQueue = /* @__PURE__ */ new Map();
@@ -70016,6 +70436,15 @@ async function investigate(input, deps) {
70016
70436
  evidenceIds: stateEvIds
70017
70437
  });
70018
70438
  }
70439
+ if (redisStateAnalysis !== null && redisStateEvIds.length > 0) {
70440
+ const dbCount = redisStateAnalysis.databases.filter((d) => d.keyCount > 0).length;
70441
+ findings2.push({
70442
+ kind: "observation",
70443
+ title: `Redis runtime state: ${redisStateEvIds.length} signal(s) across ${dbCount} DB(s)`,
70444
+ confidence: 0.5,
70445
+ evidenceIds: redisStateEvIds
70446
+ });
70447
+ }
70019
70448
  if (queueRuntimeState !== null && queueRuntimeEvIds.length > 0) {
70020
70449
  const starved = queueRuntimeState.queues.filter((q) => q.waiting >= 10 && q.active === 0);
70021
70450
  const starvedNames = new Set(starved.map((q) => q.queueName));
@@ -70162,7 +70591,7 @@ async function investigate(input, deps) {
70162
70591
  const providerReliability = {
70163
70592
  code: 0.8,
70164
70593
  ...deps.logs != null ? { logs: 0.7 } : {},
70165
- ...deps.mongo != null ? { state: 0.85 } : {},
70594
+ ...deps.mongo != null || deps.redisState != null ? { state: 0.85 } : {},
70166
70595
  ...deps.queue != null ? { queue: 0.9 } : {},
70167
70596
  ...deps.metrics != null ? { metrics: 0.75 } : {}
70168
70597
  };
@@ -73201,6 +73630,7 @@ async function runInvestigate(hint, opts) {
73201
73630
  const logs = logsForEnv(renv);
73202
73631
  const mongo = mongoForEnv(renv);
73203
73632
  const queue = queueForEnv(renv);
73633
+ const redisState = redisStateForEnv(renv);
73204
73634
  const metrics = metricsForEnv(renv);
73205
73635
  const service = opts.service ?? renv.connectors.elasticsearch?.serviceName;
73206
73636
  const { db, sql: sql2 } = createDb(config.database.url);
@@ -73213,6 +73643,7 @@ async function runInvestigate(hint, opts) {
73213
73643
  logs,
73214
73644
  mongo,
73215
73645
  queue,
73646
+ redisState,
73216
73647
  metrics,
73217
73648
  repoPath: renv.path,
73218
73649
  connectors: {
@@ -74665,6 +75096,20 @@ async function checkboxSearch(opts) {
74665
75096
  }
74666
75097
 
74667
75098
  // ../../packages/cli/src/commands/connect.ts
75099
+ function parseDbSpec(spec) {
75100
+ const [dbPart, rolesPart] = spec.split(":");
75101
+ const db = Number.parseInt((dbPart ?? "").trim(), 10);
75102
+ if (!Number.isInteger(db) || db < 0 || db > 15) {
75103
+ throw new Error(`Invalid --db spec "${spec}": DB index must be 0\u201315 (e.g. 1:bullmq,queues)`);
75104
+ }
75105
+ const roles = (rolesPart ?? "").split(",").map((r) => r.trim()).filter(Boolean);
75106
+ for (const role of roles) {
75107
+ if (!REDIS_ROLES.includes(role)) {
75108
+ throw new Error(`Invalid role "${role}" in --db spec "${spec}". Valid roles: ${REDIS_ROLES.join(", ")}`);
75109
+ }
75110
+ }
75111
+ return { db, roles };
75112
+ }
74668
75113
  var SUPPORTED = ["elasticsearch", "mongodb", "grafana", "redis"];
74669
75114
  async function runConnect(type, opts) {
74670
75115
  if (!SUPPORTED.includes(type)) {
@@ -74809,6 +75254,14 @@ ${import_picocolors27.default.bold(`Connect ${type}`)} ${import_picocolors27.def
74809
75254
  filled.url = injectRedisPassword(filled.url, pw);
74810
75255
  }
74811
75256
  }
75257
+ if (filled.db && filled.db.length > 0) {
75258
+ filled.redisDatabases = filled.db.map(parseDbSpec);
75259
+ } else if (filled.url && filled.scanDbs !== false && isInteractive()) {
75260
+ const scan = await askScanDbs();
75261
+ if (scan) {
75262
+ filled.redisDatabases = await discoverAndSelectDbs(filled.url, filled.bullmqPrefix ?? "bull");
75263
+ }
75264
+ }
74812
75265
  break;
74813
75266
  }
74814
75267
  }
@@ -74916,8 +75369,64 @@ function buildGrafanaPatch(opts) {
74916
75369
  function buildRedisPatch(opts) {
74917
75370
  const patch = {};
74918
75371
  if (opts.url) patch["url"] = opts.url;
75372
+ const dbs = opts.redisDatabases ?? (opts.db ? opts.db.map(parseDbSpec) : void 0);
75373
+ if (dbs && dbs.length > 0) {
75374
+ const prefix = opts.bullmqPrefix ?? "bull";
75375
+ patch["databases"] = dbs.map((d) => {
75376
+ const isQueue = d.roles.some((r) => r === "bullmq" || r === "queues");
75377
+ return {
75378
+ db: d.db,
75379
+ ...d.name ? { name: d.name } : {},
75380
+ roles: d.roles,
75381
+ ...isQueue ? { bullmq: { prefix } } : {}
75382
+ };
75383
+ });
75384
+ }
74919
75385
  return patch;
74920
75386
  }
75387
+ async function askScanDbs() {
75388
+ const answer = (await ask("Scan Redis DBs 0-15 to detect queues vs cache?", "yes", false)).trim().toLowerCase();
75389
+ return answer === "" || answer === "y" || answer === "yes";
75390
+ }
75391
+ function describeProbe(p) {
75392
+ if (!p.reachable) return `unreachable (${p.detail ?? "error"})`;
75393
+ if (p.keyCount === 0) return "empty";
75394
+ if (p.bullmqQueues.length > 0) {
75395
+ const sample = p.bullmqQueues.slice(0, 3).join(", ");
75396
+ return `${p.bullmqQueues.length} BullMQ queue(s) \xB7 prefix ${p.bullmqPrefix} \xB7 ${sample}`;
75397
+ }
75398
+ const examples = p.prefixes.slice(0, 3).map((x) => `${x.prefix}:*`).join(", ");
75399
+ return `${p.keyCount} keys \xB7 ${p.suggestedRoles.join("/")}${examples ? ` \xB7 ${examples}` : ""}`;
75400
+ }
75401
+ async function discoverAndSelectDbs(url, bullmqPrefix) {
75402
+ console.log(import_picocolors27.default.dim("\n Scanning DBs 0-15 (read-only, sampled)\u2026"));
75403
+ const probes = await probeRedisDatabases(url, { bullmqPrefix });
75404
+ const nonEmpty = probes.filter((p) => p.reachable && p.keyCount > 0);
75405
+ if (nonEmpty.length === 0) {
75406
+ console.log(import_picocolors27.default.dim(" No populated DBs found."));
75407
+ return [];
75408
+ }
75409
+ for (const p of nonEmpty) {
75410
+ console.log(` ${import_picocolors27.default.bold(`DB ${p.db}`)} ${import_picocolors27.default.dim("\xB7 " + describeProbe(p))}`);
75411
+ }
75412
+ const byLabel = /* @__PURE__ */ new Map();
75413
+ const choices = nonEmpty.map((p) => {
75414
+ const label = `DB ${p.db} \u2014 ${p.suggestedRoles.join("/") || "unrolled"} (${describeProbe(p)})`;
75415
+ byLabel.set(label, p);
75416
+ return label;
75417
+ });
75418
+ let selected;
75419
+ try {
75420
+ selected = await checkboxSearch({ message: "Select DBs to save", choices, pageSize: 12 });
75421
+ } catch (err) {
75422
+ if (err instanceof ExitPromptError) throw err;
75423
+ selected = choices;
75424
+ }
75425
+ return selected.map((label) => {
75426
+ const p = byLabel.get(label);
75427
+ return { db: p.db, name: p.suggestedRoles.includes("bullmq") ? "queues" : "cache", roles: p.suggestedRoles };
75428
+ });
75429
+ }
74921
75430
  async function probe(type, opts) {
74922
75431
  try {
74923
75432
  switch (type) {
@@ -74973,36 +75482,23 @@ async function probe(type, opts) {
74973
75482
  }
74974
75483
  case "redis": {
74975
75484
  if (!opts.url) return { ok: true, detail: "skipped (no URL)" };
74976
- const url = new URL(opts.url);
74977
- const port = parseInt(url.port || "6379", 10);
74978
- const host = url.hostname;
74979
- const reachable = await tcpProbe(host, port);
74980
- return reachable ? { ok: true, detail: `TCP ${host}:${port} reachable` } : { ok: false, detail: `Could not connect to ${host}:${port}` };
75485
+ const client = new RedisScanClient({ url: opts.url });
75486
+ try {
75487
+ const h = await client.health();
75488
+ const host = new URL(opts.url).hostname;
75489
+ const port = new URL(opts.url).port || "6379";
75490
+ if (h.ok) return { ok: true, detail: `PING ok at ${host}:${port}` };
75491
+ const authFailed = /WRONGPASS|NOAUTH|invalid password/i.test(h.detail);
75492
+ return { ok: false, detail: authFailed ? `auth failed (${h.detail})` : h.detail };
75493
+ } finally {
75494
+ await client.close();
75495
+ }
74981
75496
  }
74982
75497
  }
74983
75498
  } catch (err) {
74984
75499
  return { ok: false, detail: err.message };
74985
75500
  }
74986
75501
  }
74987
- function tcpProbe(host, port, timeoutMs = 3e3) {
74988
- return new Promise((resolve8) => {
74989
- const { createConnection } = require("net");
74990
- const sock = createConnection({ host, port });
74991
- const timer2 = setTimeout(() => {
74992
- sock.destroy();
74993
- resolve8(false);
74994
- }, timeoutMs);
74995
- sock.on("connect", () => {
74996
- clearTimeout(timer2);
74997
- sock.destroy();
74998
- resolve8(true);
74999
- });
75000
- sock.on("error", () => {
75001
- clearTimeout(timer2);
75002
- resolve8(false);
75003
- });
75004
- });
75005
- }
75006
75502
  function printSummary(type, opts) {
75007
75503
  const lines = [];
75008
75504
  if (opts.url) lines.push(` url: ${redactUrl(opts.url)}`);
@@ -76078,7 +76574,15 @@ Examples:
76078
76574
  });
76079
76575
  program2.command("connect <type>").description(
76080
76576
  "Add or update a runtime connector (elasticsearch / mongodb / grafana / redis) in .horus/config.json"
76081
- ).option("--env <name>", "target environment (default: first environment in config)").option("--url <url>", "connector URL or connection string (Redis with auth: redis://:password@host:6379)").option("--username <user>", "username (elasticsearch / grafana)").option("--password <pass>", "password (elasticsearch / grafana; for Redis embed in --url)").option("--index-pattern <pattern>", "Elasticsearch index pattern (required for elasticsearch)").option("--service <name>", "service name scope for log queries").option("--database <name>", "database name (required for mongodb)").option("--collections <list>", "comma-separated collection allowlist (mongodb)").option("--dashboard <uid>", "default dashboard uid (grafana)").option("--no-test", "skip live connection probe").action(
76577
+ ).option("--env <name>", "target environment (default: first environment in config)").option("--url <url>", "connector URL or connection string (Redis with auth: redis://:password@host:6379)").option("--username <user>", "username (elasticsearch / grafana)").option("--password <pass>", "password (elasticsearch / grafana; for Redis embed in --url)").option("--index-pattern <pattern>", "Elasticsearch index pattern (required for elasticsearch)").option("--service <name>", "service name scope for log queries").option("--database <name>", "database name (required for mongodb)").option("--collections <list>", "comma-separated collection allowlist (mongodb)").option("--dashboard <uid>", "default dashboard uid (grafana)").option(
76578
+ "--db <spec>",
76579
+ "redis logical DB as db:role1,role2 (e.g. 0:cache,state or 1:bullmq,queues); repeatable",
76580
+ (val, acc) => {
76581
+ acc.push(val);
76582
+ return acc;
76583
+ },
76584
+ []
76585
+ ).option("--bullmq-prefix <prefix>", "BullMQ key prefix for redis queue DBs (default: bull)").option("--no-scan-dbs", "skip interactive Redis DB scan").option("--no-test", "skip live connection probe").action(
76082
76586
  async (type, opts) => {
76083
76587
  process.exitCode = await runConnect(type, {
76084
76588
  env: opts.env,
@@ -76090,6 +76594,9 @@ Examples:
76090
76594
  database: opts.database,
76091
76595
  collections: opts.collections,
76092
76596
  dashboard: opts.dashboard,
76597
+ db: opts.db,
76598
+ bullmqPrefix: opts.bullmqPrefix,
76599
+ scanDbs: opts.scanDbs,
76093
76600
  noTest: opts.test === false
76094
76601
  });
76095
76602
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@merittdev/horus",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Local-first, source-aware production-incident investigation engine",
5
5
  "type": "module",
6
6
  "bin": {