@junando/core 0.10.1 → 0.11.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 (3) hide show
  1. package/dist/index.d.ts +1456 -5
  2. package/dist/index.js +512 -47
  3. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -230,6 +230,7 @@ var AlertmanagerPayloadSchema = z.object({
230
230
 
231
231
  // src/domain/entities/cluster.ts
232
232
  import { z as z2 } from "zod";
233
+ var SEVERITY_VALUES = ["critical", "high", "medium", "low"];
233
234
  var AlertClusterSchema = z2.object({
234
235
  fingerprint: z2.string(),
235
236
  serviceName: z2.string(),
@@ -239,7 +240,11 @@ var AlertClusterSchema = z2.object({
239
240
  alertCount: z2.number().int().positive(),
240
241
  representativeTraceIds: z2.array(z2.string()).max(2),
241
242
  firstSeenAt: z2.string().datetime(),
242
- latencyP99Ms: z2.number().optional()
243
+ latencyP99Ms: z2.number().optional(),
244
+ /** Severity level — can be derived from ALERT_TYPE_LABELS[alertType].severity */
245
+ severity: z2.enum(SEVERITY_VALUES).optional(),
246
+ /** Arbitrary key-value labels passed through from alerts */
247
+ labels: z2.record(z2.string()).optional()
243
248
  });
244
249
 
245
250
  // src/domain/entities/incident.ts
@@ -260,6 +265,73 @@ var IncidentSchema = z3.object({
260
265
  processedAt: z3.string().datetime()
261
266
  });
262
267
 
268
+ // src/domain/entities/rule.ts
269
+ import { z as z4 } from "zod";
270
+ var RuleActionType = /* @__PURE__ */ ((RuleActionType2) => {
271
+ RuleActionType2["Suppress"] = "suppress";
272
+ RuleActionType2["Route"] = "route";
273
+ RuleActionType2["Escalate"] = "escalate";
274
+ RuleActionType2["Tag"] = "tag";
275
+ return RuleActionType2;
276
+ })(RuleActionType || {});
277
+ var SeverityLevel = /* @__PURE__ */ ((SeverityLevel2) => {
278
+ SeverityLevel2["Critical"] = "critical";
279
+ SeverityLevel2["High"] = "high";
280
+ SeverityLevel2["Medium"] = "medium";
281
+ SeverityLevel2["Low"] = "low";
282
+ return SeverityLevel2;
283
+ })(SeverityLevel || {});
284
+ var RuleEvaluationPhase = /* @__PURE__ */ ((RuleEvaluationPhase2) => {
285
+ RuleEvaluationPhase2["PreLlm"] = "pre-llm";
286
+ RuleEvaluationPhase2["PostLlm"] = "post-llm";
287
+ return RuleEvaluationPhase2;
288
+ })(RuleEvaluationPhase || {});
289
+ var AlertCountSchema = z4.object({
290
+ min: z4.number().optional(),
291
+ max: z4.number().optional()
292
+ });
293
+ var LatencySchema = z4.object({
294
+ min: z4.number().optional(),
295
+ max: z4.number().optional()
296
+ });
297
+ var RuleConditionSchema = z4.object({
298
+ serviceName: z4.string().optional(),
299
+ alertType: z4.nativeEnum(AlertType).optional(),
300
+ severity: z4.nativeEnum(SeverityLevel).optional(),
301
+ labels: z4.record(z4.string()).optional(),
302
+ endpointPath: z4.string().optional(),
303
+ alertCount: AlertCountSchema.optional(),
304
+ latencyP99Ms: LatencySchema.optional(),
305
+ urgencyLevel: UrgencyLevelSchema.optional(),
306
+ requiresRollback: z4.boolean().optional(),
307
+ impactedServices: z4.array(z4.string()).optional()
308
+ });
309
+ var SUPPRESS_SCHEMA = z4.object({ type: z4.literal("suppress" /* Suppress */) });
310
+ var ROUTE_SCHEMA = z4.object({ type: z4.literal("route" /* Route */), channel: z4.string().min(1) });
311
+ var ESCALATE_SCHEMA = z4.object({ type: z4.literal("escalate" /* Escalate */), channel: z4.string().min(1) });
312
+ var TAG_SCHEMA = z4.object({ type: z4.literal("tag" /* Tag */), key: z4.string().min(1), value: z4.string().min(1) });
313
+ var RuleActionSchema = z4.discriminatedUnion("type", [
314
+ SUPPRESS_SCHEMA,
315
+ ROUTE_SCHEMA,
316
+ ESCALATE_SCHEMA,
317
+ TAG_SCHEMA
318
+ ]);
319
+ var RuleSchema = z4.object({
320
+ id: z4.string().min(1),
321
+ name: z4.string().optional(),
322
+ condition: RuleConditionSchema,
323
+ actions: z4.array(RuleActionSchema).min(1),
324
+ urgencyLevel: UrgencyLevelSchema.optional(),
325
+ requiresRollback: z4.boolean().optional()
326
+ });
327
+ var RuleSectionSchema = z4.object({
328
+ rules: z4.array(RuleSchema).default([])
329
+ });
330
+ var RuleConfigurationSchema = z4.object({
331
+ ["pre-llm" /* PreLlm */]: RuleSectionSchema,
332
+ ["post-llm" /* PostLlm */]: RuleSectionSchema
333
+ });
334
+
263
335
  // src/domain/value-objects/fingerprint.ts
264
336
  import { createHash } from "crypto";
265
337
  var Fingerprint = class _Fingerprint {
@@ -383,6 +455,7 @@ __export(metrics_exports, {
383
455
  pipelineInlineFailuresTotal: () => pipelineInlineFailuresTotal,
384
456
  registry: () => registry,
385
457
  sqsQueueLag: () => sqsQueueLag,
458
+ suppressedClusters: () => suppressedClusters,
386
459
  webhookRequestsTotal: () => webhookRequestsTotal
387
460
  });
388
461
  import { Registry, Counter, Gauge, Histogram } from "prom-client";
@@ -465,6 +538,12 @@ var sqsQueueLag = new Gauge({
465
538
  labelNames: ["queue_name"],
466
539
  registers: [registry]
467
540
  });
541
+ var suppressedClusters = new Gauge({
542
+ name: "junando_suppressed_clusters",
543
+ help: "Current number of clusters suppressed by the rule engine",
544
+ labelNames: ["rule_id"],
545
+ registers: [registry]
546
+ });
468
547
 
469
548
  // src/application/use-cases/process-incident.use-case.ts
470
549
  var ProcessIncidentUseCase = class {
@@ -475,7 +554,7 @@ var ProcessIncidentUseCase = class {
475
554
  deps;
476
555
  clustering;
477
556
  async execute(alerts, correlationId) {
478
- const { dedup, traces, llm, notifier, logger: logger5, dedupTtlSeconds } = this.deps;
557
+ const { dedup, traces, llm, notifier, logger: logger5, dedupTtlSeconds, ruleEngine } = this.deps;
479
558
  const log = logger5.child({ correlationId, useCase: "ProcessIncident" });
480
559
  log.info({ alertCount: alerts.length }, "Processing alert batch");
481
560
  const clusters = this.clustering.buildClusters(alerts);
@@ -490,6 +569,26 @@ var ProcessIncidentUseCase = class {
490
569
  continue;
491
570
  }
492
571
  dedupNew.inc({ source: "alertmanager" });
572
+ let preLlmRouteChannels = [];
573
+ let preLlmEscalateChannels = [];
574
+ if (ruleEngine) {
575
+ const preResult = ruleEngine.evaluatePreLlm(cluster);
576
+ if (preResult.suppressed) {
577
+ log2.info({ matchedRuleId: preResult.matchedRuleId }, "Cluster suppressed by rule engine");
578
+ if (preResult.matchedRuleId) {
579
+ suppressedClusters.inc({ rule_id: preResult.matchedRuleId });
580
+ }
581
+ continue;
582
+ }
583
+ for (const action of preResult.actions) {
584
+ if (action.type === "route" && "channel" in action) {
585
+ preLlmRouteChannels.push(action.channel);
586
+ }
587
+ if (action.type === "escalate" && "channel" in action) {
588
+ preLlmEscalateChannels.push(action.channel);
589
+ }
590
+ }
591
+ }
493
592
  const spanLists = await Promise.all(
494
593
  cluster.representativeTraceIds.map(
495
594
  (id) => traces.findByTraceId(id).catch((err) => {
@@ -507,8 +606,31 @@ var ProcessIncidentUseCase = class {
507
606
  } catch (err) {
508
607
  log2.warn({ err }, "LLM inference failed \u2014 notifying without diagnosis");
509
608
  }
609
+ let postLlmEscalateChannels = [];
610
+ if (ruleEngine && analysis) {
611
+ const postResult = ruleEngine.evaluatePostLlm(cluster, analysis);
612
+ for (const action of postResult.actions) {
613
+ if (action.type === "escalate" && "channel" in action) {
614
+ postLlmEscalateChannels.push(action.channel);
615
+ }
616
+ if (action.type === "tag" && "key" in action) {
617
+ log2.info({ tagKey: action.key, tagValue: action.value }, "Tag attached to cluster");
618
+ }
619
+ }
620
+ if (postResult.tags && Object.keys(postResult.tags).length > 0) {
621
+ cluster.labels = { ...cluster.labels, ...postResult.tags };
622
+ }
623
+ }
510
624
  try {
511
- await notifier.send(cluster, analysis);
625
+ const primaryChannel = preLlmRouteChannels[0];
626
+ const escalateChannels = [
627
+ ...preLlmEscalateChannels,
628
+ ...postLlmEscalateChannels
629
+ ];
630
+ await notifier.send(cluster, analysis, primaryChannel);
631
+ for (const channel of escalateChannels) {
632
+ await notifier.send(cluster, analysis, channel);
633
+ }
512
634
  log2.info("Notification sent");
513
635
  } catch (err) {
514
636
  log2.error({ err }, "Notification failed");
@@ -727,24 +849,24 @@ var InMemoryIndexer = class {
727
849
 
728
850
  // src/infrastructure/llm/llm.adapter.ts
729
851
  import * as Breaker from "opossum";
730
- import { z as z4 } from "zod";
852
+ import { z as z5 } from "zod";
731
853
  var logger2 = createLogger();
732
- var OpenRouterResponseSchema = z4.object({
733
- id: z4.string().optional(),
734
- choices: z4.array(
735
- z4.object({
736
- index: z4.number(),
737
- message: z4.object({
738
- role: z4.string(),
739
- content: z4.string().optional()
854
+ var OpenRouterResponseSchema = z5.object({
855
+ id: z5.string().optional(),
856
+ choices: z5.array(
857
+ z5.object({
858
+ index: z5.number(),
859
+ message: z5.object({
860
+ role: z5.string(),
861
+ content: z5.string().optional()
740
862
  }),
741
- finish_reason: z4.string().optional()
863
+ finish_reason: z5.string().optional()
742
864
  })
743
865
  ),
744
- usage: z4.object({
745
- prompt_tokens: z4.number().optional(),
746
- completion_tokens: z4.number().optional(),
747
- total_tokens: z4.number().optional()
866
+ usage: z5.object({
867
+ prompt_tokens: z5.number().optional(),
868
+ completion_tokens: z5.number().optional(),
869
+ total_tokens: z5.number().optional()
748
870
  }).optional()
749
871
  });
750
872
  var SYSTEM_PROMPT = `You are a senior Site Reliability Engineer.
@@ -1005,6 +1127,9 @@ function createLLMProvider(provider, apiKey, model, options) {
1005
1127
  return factory(apiKey, model, options);
1006
1128
  }
1007
1129
 
1130
+ // src/infrastructure/notifier/factory.ts
1131
+ import { readFileSync } from "fs";
1132
+
1008
1133
  // src/shared/factory-registry.ts
1009
1134
  var FactoryRegistry = class {
1010
1135
  _factories = /* @__PURE__ */ new Map();
@@ -1062,7 +1187,7 @@ var SlackNotifier = class {
1062
1187
  }
1063
1188
  botToken;
1064
1189
  channel;
1065
- async send(cluster, analysis) {
1190
+ async send(cluster, analysis, _channel) {
1066
1191
  const payload = analysis ? this.buildAnalysisMessage(cluster, analysis) : this.buildFallbackMessage(cluster);
1067
1192
  try {
1068
1193
  const res = await fetch(SLACK_API_URL, {
@@ -1184,7 +1309,7 @@ LLM analysis unavailable \u2014 manual investigation required.`
1184
1309
  };
1185
1310
  var ConsoleNotifier = class {
1186
1311
  sent = [];
1187
- async send(cluster, analysis) {
1312
+ async send(cluster, analysis, _channel) {
1188
1313
  try {
1189
1314
  this.sent.push({ cluster, analysis });
1190
1315
  logger3.info(
@@ -1341,7 +1466,7 @@ var TeamsNotifier = class {
1341
1466
  // If parsing happens inside the catch block and throws, the original error
1342
1467
  // context (timeout, network failure, etc.) would be lost.
1343
1468
  hostForErrors;
1344
- async send(cluster, analysis) {
1469
+ async send(cluster, analysis, _channel) {
1345
1470
  const card = analysis ? buildAnalysisCard(cluster, analysis) : buildFallbackCard(cluster);
1346
1471
  const payload = buildAdaptiveCardPayload(card);
1347
1472
  const controller = new AbortController();
@@ -1379,6 +1504,320 @@ var TeamsNotifier = class {
1379
1504
  }
1380
1505
  };
1381
1506
 
1507
+ // src/infrastructure/notifier/routing-notifier.ts
1508
+ var ACTION_DISPATCH = {
1509
+ ["suppress" /* Suppress */]: async (_action, ctx) => {
1510
+ ctx.suppressed = true;
1511
+ },
1512
+ ["route" /* Route */]: async (action, ctx) => {
1513
+ const routeAction = action;
1514
+ ctx.routeChannels.add(routeAction.channel);
1515
+ },
1516
+ ["escalate" /* Escalate */]: async (action, ctx) => {
1517
+ const escalateAction = action;
1518
+ ctx.escalationChannels.add(escalateAction.channel);
1519
+ },
1520
+ ["tag" /* Tag */]: async (_action, _ctx) => {
1521
+ }
1522
+ };
1523
+ var RoutingNotifier = class {
1524
+ constructor(registry2, defaultNotifier) {
1525
+ this.registry = registry2;
1526
+ this.defaultNotifier = defaultNotifier;
1527
+ }
1528
+ registry;
1529
+ defaultNotifier;
1530
+ /**
1531
+ * Implements INotifier.send — sends via default notifier.
1532
+ * Backward-compatible with existing call sites that don't use rule actions.
1533
+ */
1534
+ async send(cluster, analysis) {
1535
+ await this.defaultNotifier.send(cluster, analysis);
1536
+ }
1537
+ /**
1538
+ * Dispatch notifications based on rule engine actions.
1539
+ *
1540
+ * - Route actions: send to the specified channel instead of default.
1541
+ * - Escalate actions: send additional notifications to escalation channels.
1542
+ * - Tag actions: metadata-only, no notification side effect.
1543
+ * - Suppress actions: skip all notification (defensive — caller should have already skipped).
1544
+ * - Unknown channels: fall back to default notifier.
1545
+ * - Empty actions or no Route/Escalate: send via default notifier.
1546
+ */
1547
+ async sendWithActions(cluster, analysis, actions) {
1548
+ const ctx = {
1549
+ cluster,
1550
+ analysis,
1551
+ registry: this.registry,
1552
+ defaultNotifier: this.defaultNotifier,
1553
+ routeChannels: /* @__PURE__ */ new Set(),
1554
+ escalationChannels: /* @__PURE__ */ new Set(),
1555
+ suppressed: false
1556
+ };
1557
+ for (const action of actions) {
1558
+ const handler = ACTION_DISPATCH[action.type];
1559
+ if (handler) {
1560
+ await handler(action, ctx);
1561
+ }
1562
+ }
1563
+ if (ctx.suppressed) {
1564
+ return;
1565
+ }
1566
+ const notifications = [];
1567
+ const hasRoute = ctx.routeChannels.size > 0;
1568
+ if (hasRoute) {
1569
+ for (const channel of ctx.routeChannels) {
1570
+ notifications.push(this.tryResolveAndSend(channel, cluster, analysis));
1571
+ }
1572
+ } else {
1573
+ notifications.push(this.defaultNotifier.send(cluster, analysis));
1574
+ }
1575
+ for (const channel of ctx.escalationChannels) {
1576
+ notifications.push(this.tryResolveAndSend(channel, cluster, analysis));
1577
+ }
1578
+ await Promise.all(notifications);
1579
+ }
1580
+ // ── Private helpers ──────────────────────────────────────────────────────
1581
+ /**
1582
+ * Resolve a channel name to its notifier and send.
1583
+ * Falls back to default notifier if channel is unknown.
1584
+ */
1585
+ async tryResolveAndSend(channel, cluster, analysis) {
1586
+ try {
1587
+ const notifier = this.registry.resolve(channel);
1588
+ await notifier.send(cluster, analysis);
1589
+ } catch {
1590
+ await this.defaultNotifier.send(cluster, analysis);
1591
+ }
1592
+ }
1593
+ };
1594
+
1595
+ // src/infrastructure/rules/yaml-rule-loader.ts
1596
+ import { parse as parseYaml, YAMLParseError } from "yaml";
1597
+ function parseRuleConfig(yamlString) {
1598
+ let raw;
1599
+ try {
1600
+ raw = parseYaml(yamlString);
1601
+ } catch (err) {
1602
+ if (err instanceof YAMLParseError) {
1603
+ throw new Error(`Invalid YAML in rules config: ${err.message}`);
1604
+ }
1605
+ throw err;
1606
+ }
1607
+ const result = RuleConfigurationSchema.safeParse(raw);
1608
+ if (!result.success) {
1609
+ const issues = result.error.issues.map((i3) => ` - ${i3.path.join(".")}: ${i3.message}`).join("\n");
1610
+ throw new Error(`Invalid rules configuration:
1611
+ ${issues}`);
1612
+ }
1613
+ return result.data;
1614
+ }
1615
+
1616
+ // src/infrastructure/rules/channel-registry.ts
1617
+ var ChannelRegistry = class {
1618
+ _channels = /* @__PURE__ */ new Map();
1619
+ _default = null;
1620
+ /**
1621
+ * Register a notifier for a given channel name.
1622
+ * Overwrites any existing registration for that name.
1623
+ */
1624
+ register(channel, notifier) {
1625
+ this._channels.set(channel, notifier);
1626
+ }
1627
+ /**
1628
+ * Set the default notifier to use when a channel is not found.
1629
+ */
1630
+ setDefault(notifier) {
1631
+ this._default = notifier;
1632
+ }
1633
+ /**
1634
+ * Resolve a channel name to its notifier instance.
1635
+ * Falls back to the default notifier if the channel is unknown.
1636
+ *
1637
+ * @throws {Error} if the channel is unknown and no default is set
1638
+ */
1639
+ resolve(channel) {
1640
+ const instance = this._channels.get(channel);
1641
+ if (instance) {
1642
+ return instance;
1643
+ }
1644
+ if (this._default) {
1645
+ return this._default;
1646
+ }
1647
+ throw new Error(
1648
+ `Unknown channel "${channel}" and no default notifier configured`
1649
+ );
1650
+ }
1651
+ /**
1652
+ * Check if a channel is registered.
1653
+ */
1654
+ has(channel) {
1655
+ return this._channels.has(channel);
1656
+ }
1657
+ };
1658
+
1659
+ // src/infrastructure/rules/condition-evaluator.ts
1660
+ var MATCHER_MAP = {
1661
+ serviceName: (value) => {
1662
+ const target = value.toLowerCase();
1663
+ return (cluster) => cluster.serviceName.toLowerCase() === target;
1664
+ },
1665
+ alertType: (value) => {
1666
+ return (cluster) => cluster.alertType === value;
1667
+ },
1668
+ severity: (value) => {
1669
+ return (cluster) => {
1670
+ const config = ALERT_TYPE_LABELS[cluster.alertType];
1671
+ return config?.severity === value;
1672
+ };
1673
+ },
1674
+ endpointPath: (value) => {
1675
+ return (cluster) => cluster.endpointPath === value;
1676
+ },
1677
+ alertCount: (value) => {
1678
+ const range = value;
1679
+ return (cluster) => {
1680
+ const count = cluster.alertCount;
1681
+ if (range.min !== void 0 && count < range.min) return false;
1682
+ if (range.max !== void 0 && count > range.max) return false;
1683
+ return true;
1684
+ };
1685
+ },
1686
+ latencyP99Ms: (value) => {
1687
+ const range = value;
1688
+ return (cluster) => {
1689
+ const latency2 = cluster.latencyP99Ms;
1690
+ if (latency2 === void 0) return false;
1691
+ if (range.min !== void 0 && latency2 < range.min) return false;
1692
+ if (range.max !== void 0 && latency2 > range.max) return false;
1693
+ return true;
1694
+ };
1695
+ },
1696
+ labels: (value) => {
1697
+ const expected = value;
1698
+ return (cluster) => {
1699
+ const clusterLabels = cluster.labels;
1700
+ if (!clusterLabels) return false;
1701
+ return Object.entries(expected).every(
1702
+ ([key, val]) => clusterLabels[key] === val
1703
+ );
1704
+ };
1705
+ },
1706
+ urgencyLevel: (value) => {
1707
+ return (_cluster, analysis) => {
1708
+ if (!analysis) return false;
1709
+ return analysis.urgency_level === value;
1710
+ };
1711
+ },
1712
+ requiresRollback: (value) => {
1713
+ return (_cluster, analysis) => {
1714
+ if (!analysis) return false;
1715
+ return analysis.requires_rollback === value;
1716
+ };
1717
+ },
1718
+ impactedServices: (value) => {
1719
+ const targets = value;
1720
+ return (_cluster, analysis) => {
1721
+ if (!analysis) return false;
1722
+ return targets.some((t) => analysis.impacted_services.includes(t));
1723
+ };
1724
+ }
1725
+ };
1726
+ function compileCondition(condition) {
1727
+ const predicates = [];
1728
+ for (const [field, value] of Object.entries(condition)) {
1729
+ if (value === void 0) continue;
1730
+ const factory = MATCHER_MAP[field];
1731
+ if (factory) {
1732
+ predicates.push(factory(value));
1733
+ }
1734
+ }
1735
+ if (predicates.length === 0) {
1736
+ return () => true;
1737
+ }
1738
+ return (cluster, analysis) => predicates.every((p) => p(cluster, analysis));
1739
+ }
1740
+
1741
+ // src/infrastructure/rules/action-dispatcher.ts
1742
+ var HANDLER_MAP = {
1743
+ ["suppress" /* Suppress */]: (_action, result) => {
1744
+ result.suppressed = true;
1745
+ },
1746
+ ["route" /* Route */]: (action, result) => {
1747
+ result.actions.push(action);
1748
+ },
1749
+ ["escalate" /* Escalate */]: (action, result) => {
1750
+ result.actions.push(action);
1751
+ },
1752
+ ["tag" /* Tag */]: (action, result) => {
1753
+ const tagAction = action;
1754
+ result.tags[tagAction.key] = tagAction.value;
1755
+ }
1756
+ };
1757
+ function dispatchActions(actions) {
1758
+ const result = {
1759
+ suppressed: false,
1760
+ actions: [],
1761
+ tags: {}
1762
+ };
1763
+ for (const action of actions) {
1764
+ const handler = HANDLER_MAP[action.type];
1765
+ if (handler) {
1766
+ handler(action, result);
1767
+ }
1768
+ }
1769
+ return result;
1770
+ }
1771
+
1772
+ // src/infrastructure/rules/rule-engine.ts
1773
+ var RuleEngine = class {
1774
+ preLlmRules;
1775
+ postLlmRules;
1776
+ constructor(config) {
1777
+ this.preLlmRules = this.compileSection(config["pre-llm" /* PreLlm */].rules);
1778
+ this.postLlmRules = this.compileSection(config["post-llm" /* PostLlm */].rules);
1779
+ }
1780
+ /**
1781
+ * Evaluate PRE-LLM rules against a cluster.
1782
+ * First-match-wins — returns result of first matching rule.
1783
+ * If no rule matches, returns pass-through (suppressed=false, no actions).
1784
+ */
1785
+ evaluatePreLlm(cluster) {
1786
+ return this.evaluateRules(this.preLlmRules, cluster);
1787
+ }
1788
+ /**
1789
+ * Evaluate POST-LLM rules against a cluster and LLM analysis.
1790
+ * First-match-wins — returns result of first matching rule.
1791
+ * If no rule matches, returns pass-through.
1792
+ */
1793
+ evaluatePostLlm(cluster, analysis) {
1794
+ return this.evaluateRules(this.postLlmRules, cluster, analysis);
1795
+ }
1796
+ // ── Private helpers ──────────────────────────────────────────────────────
1797
+ compileSection(rules) {
1798
+ return rules.map((rule) => ({
1799
+ id: rule.id,
1800
+ predicate: compileCondition(rule.condition),
1801
+ result: dispatchActions(rule.actions)
1802
+ }));
1803
+ }
1804
+ evaluateRules(rules, cluster, analysis) {
1805
+ for (const rule of rules) {
1806
+ if (rule.predicate(cluster, analysis)) {
1807
+ return {
1808
+ ...rule.result,
1809
+ matchedRuleId: rule.id
1810
+ };
1811
+ }
1812
+ }
1813
+ return {
1814
+ suppressed: false,
1815
+ actions: [],
1816
+ tags: {}
1817
+ };
1818
+ }
1819
+ };
1820
+
1382
1821
  // src/infrastructure/notifier/factory.ts
1383
1822
  function buildNotifierRegistry(config) {
1384
1823
  const registry2 = new FactoryRegistry();
@@ -1399,7 +1838,16 @@ function buildNotifierRegistry(config) {
1399
1838
  }
1400
1839
  function createNotifier(config) {
1401
1840
  const registry2 = buildNotifierRegistry(config);
1402
- return registry2.resolve(config.notifierType);
1841
+ const defaultNotifier = registry2.resolve(config.notifierType);
1842
+ if (!config.rulesConfigPath) {
1843
+ console.info("[createNotifier] RULES_CONFIG_PATH not set \u2014 rule engine disabled, using default notifier");
1844
+ return defaultNotifier;
1845
+ }
1846
+ const yamlContent = readFileSync(config.rulesConfigPath, "utf-8");
1847
+ parseRuleConfig(yamlContent);
1848
+ const channelRegistry = new ChannelRegistry();
1849
+ channelRegistry.setDefault(defaultNotifier);
1850
+ return new RoutingNotifier(channelRegistry, defaultNotifier);
1403
1851
  }
1404
1852
 
1405
1853
  // ../../node_modules/.pnpm/@smithy+smithy-client@4.12.13/node_modules/@smithy/smithy-client/dist-es/NoOpLogger.js
@@ -9799,7 +10247,7 @@ var GetParametersCommand = class extends Command.classBuilder().ep(commonParams2
9799
10247
  };
9800
10248
 
9801
10249
  // src/shared/config/index.ts
9802
- import { z as z5 } from "zod";
10250
+ import { z as z6 } from "zod";
9803
10251
  async function loadSecretsFromSSM() {
9804
10252
  const prefix = process.env.SSM_PREFIX;
9805
10253
  if (!prefix) {
@@ -9835,51 +10283,53 @@ async function loadSecretsFromSSM() {
9835
10283
  createLogger().error({ err }, "Failed to load SSM parameters");
9836
10284
  }
9837
10285
  }
9838
- var ConfigSchema = z5.object({
9839
- llmProvider: z5.enum(["gemini", "claude", "openrouter", "qwen"]),
9840
- llmApiKey: z5.string().min(1),
9841
- llmModel: z5.string().optional().transform((v) => v === "" ? void 0 : v),
10286
+ var ConfigSchema = z6.object({
10287
+ llmProvider: z6.enum(["gemini", "claude", "openrouter", "qwen"]),
10288
+ llmApiKey: z6.string().min(1),
10289
+ llmModel: z6.string().optional().transform((v) => v === "" ? void 0 : v),
9842
10290
  // Notifier selector — defaults to 'slack' for backward compatibility
9843
- notifierType: z5.enum(["slack", "teams"]).default("slack"),
10291
+ notifierType: z6.enum(["slack", "teams"]).default("slack"),
9844
10292
  // Slack fields — optional at schema level; superRefine enforces them conditionally
9845
- slackBotToken: z5.string().startsWith("xoxb-").optional(),
9846
- slackSigningSecret: z5.string().min(1).optional(),
9847
- slackChannel: z5.string().startsWith("#").optional(),
10293
+ slackBotToken: z6.string().startsWith("xoxb-").optional(),
10294
+ slackSigningSecret: z6.string().min(1).optional(),
10295
+ slackChannel: z6.string().startsWith("#").optional(),
9848
10296
  // Teams field
9849
- teamsWebhookUrl: z5.string().url().optional(),
9850
- lokiUrl: z5.string().optional().transform((v) => v === "" ? void 0 : v),
10297
+ teamsWebhookUrl: z6.string().url().optional(),
10298
+ lokiUrl: z6.string().optional().transform((v) => v === "" ? void 0 : v),
9851
10299
  // URL with embedded credentials — skip .url() which rejects user:pass@ format. Optional: containers may run without Loki; logger falls back to stdout. Empty string is coerced to undefined (env var unset vs empty are equivalent).
9852
- redisUrl: z5.string().url(),
9853
- sqsQueueUrl: z5.string().url().optional().or(z5.literal("")),
9854
- dedupTtlSeconds: z5.coerce.number().int().positive().default(300),
9855
- clusterWindowMs: z5.coerce.number().int().positive().default(12e4),
9856
- logLevel: z5.enum(["trace", "debug", "info", "warn", "error"]).default("info"),
9857
- nodeEnv: z5.enum(["development", "test", "production"]).default("development"),
9858
- llmFallbackModels: z5.string().optional().transform((v) => {
10300
+ redisUrl: z6.string().url(),
10301
+ sqsQueueUrl: z6.string().url().optional().or(z6.literal("")),
10302
+ dedupTtlSeconds: z6.coerce.number().int().positive().default(300),
10303
+ clusterWindowMs: z6.coerce.number().int().positive().default(12e4),
10304
+ logLevel: z6.enum(["trace", "debug", "info", "warn", "error"]).default("info"),
10305
+ nodeEnv: z6.enum(["development", "test", "production"]).default("development"),
10306
+ llmFallbackModels: z6.string().optional().transform((v) => {
9859
10307
  if (v === void 0) return LLM_FALLBACK_DEFAULTS.Models;
9860
10308
  if (!v) return [];
9861
10309
  return v.split(",").map((s) => s.trim()).filter(Boolean);
9862
10310
  }),
9863
- llmFallbackTimeoutMs: z5.coerce.number().int().positive().default(LLM_FALLBACK_DEFAULTS.TimeoutMs)
10311
+ llmFallbackTimeoutMs: z6.coerce.number().int().positive().default(LLM_FALLBACK_DEFAULTS.TimeoutMs),
10312
+ // Optional path to rules.yaml for business rules engine. When not set, rule engine is disabled.
10313
+ rulesConfigPath: z6.string().optional().transform((v) => v === "" ? void 0 : v)
9864
10314
  }).superRefine((data, ctx) => {
9865
10315
  if (data.notifierType === "slack") {
9866
10316
  if (!data.slackBotToken) {
9867
10317
  ctx.addIssue({
9868
- code: z5.ZodIssueCode.custom,
10318
+ code: z6.ZodIssueCode.custom,
9869
10319
  path: ["slackBotToken"],
9870
10320
  message: "[notifierType: slack] SLACK_BOT_TOKEN is required and must start with xoxb-"
9871
10321
  });
9872
10322
  }
9873
10323
  if (!data.slackChannel) {
9874
10324
  ctx.addIssue({
9875
- code: z5.ZodIssueCode.custom,
10325
+ code: z6.ZodIssueCode.custom,
9876
10326
  path: ["slackChannel"],
9877
10327
  message: "[notifierType: slack] SLACK_CHANNEL is required and must start with #"
9878
10328
  });
9879
10329
  }
9880
10330
  if (!data.slackSigningSecret) {
9881
10331
  ctx.addIssue({
9882
- code: z5.ZodIssueCode.custom,
10332
+ code: z6.ZodIssueCode.custom,
9883
10333
  path: ["slackSigningSecret"],
9884
10334
  message: "[notifierType: slack] SLACK_SIGNING_SECRET is required (used to verify Slack interactivity callbacks)"
9885
10335
  });
@@ -9888,7 +10338,7 @@ var ConfigSchema = z5.object({
9888
10338
  if (data.notifierType === "teams") {
9889
10339
  if (!data.teamsWebhookUrl) {
9890
10340
  ctx.addIssue({
9891
- code: z5.ZodIssueCode.custom,
10341
+ code: z6.ZodIssueCode.custom,
9892
10342
  path: ["teamsWebhookUrl"],
9893
10343
  message: "[notifierType: teams] TEAMS_WEBHOOK_URL is required"
9894
10344
  });
@@ -9898,14 +10348,14 @@ var ConfigSchema = z5.object({
9898
10348
  parsed = new URL(data.teamsWebhookUrl);
9899
10349
  } catch {
9900
10350
  ctx.addIssue({
9901
- code: z5.ZodIssueCode.custom,
10351
+ code: z6.ZodIssueCode.custom,
9902
10352
  path: ["teamsWebhookUrl"],
9903
10353
  message: "[notifierType: teams] TEAMS_WEBHOOK_URL must be a valid URL"
9904
10354
  });
9905
10355
  }
9906
10356
  if (parsed && !parsed.searchParams.has("api-version")) {
9907
10357
  ctx.addIssue({
9908
- code: z5.ZodIssueCode.custom,
10358
+ code: z6.ZodIssueCode.custom,
9909
10359
  path: ["teamsWebhookUrl"],
9910
10360
  message: "[notifierType: teams] TEAMS_WEBHOOK_URL must include api-version= as a query parameter"
9911
10361
  });
@@ -9932,7 +10382,8 @@ async function loadConfig2() {
9932
10382
  logLevel: process.env["LOG_LEVEL"],
9933
10383
  nodeEnv: process.env["NODE_ENV"],
9934
10384
  llmFallbackModels: process.env["LLM_FALLBACK_MODELS"],
9935
- llmFallbackTimeoutMs: process.env["LLM_FALLBACK_TIMEOUT_MS"]
10385
+ llmFallbackTimeoutMs: process.env["LLM_FALLBACK_TIMEOUT_MS"],
10386
+ rulesConfigPath: process.env["RULES_CONFIG_PATH"]
9936
10387
  });
9937
10388
  if (!result.success) {
9938
10389
  const errorMessages = result.error.issues.map(
@@ -9950,6 +10401,7 @@ export {
9950
10401
  AlertType,
9951
10402
  AlertmanagerPayloadSchema,
9952
10403
  CIRCUIT_BREAKER,
10404
+ ChannelRegistry,
9953
10405
  ClaudeProvider,
9954
10406
  ClusteringService,
9955
10407
  ConsoleNotifier,
@@ -9979,8 +10431,18 @@ export {
9979
10431
  RATE_LIMITER,
9980
10432
  REDIS_KEY_PREFIX,
9981
10433
  RedisDeduplicationStore,
10434
+ RoutingNotifier,
10435
+ RuleActionSchema,
10436
+ RuleActionType,
10437
+ RuleConditionSchema,
10438
+ RuleConfigurationSchema,
10439
+ RuleEngine,
10440
+ RuleEvaluationPhase,
10441
+ RuleSchema,
10442
+ RuleSectionSchema,
9982
10443
  SLACK_API_URL,
9983
10444
  SQSAlertQueue,
10445
+ SeverityLevel,
9984
10446
  SlackNotifier,
9985
10447
  TEAMS_WEBHOOK_TIMEOUT_MS,
9986
10448
  TeamsNotifier,
@@ -9988,13 +10450,16 @@ export {
9988
10450
  URGENCY_EMOJI,
9989
10451
  UrgencyLevelSchema,
9990
10452
  WEBHOOK_DEFAULTS,
10453
+ compileCondition,
9991
10454
  createLLMProvider,
9992
10455
  createLogger,
9993
10456
  createNotifier,
10457
+ dispatchActions,
9994
10458
  flushLoki,
9995
10459
  loadConfig2 as loadConfig,
9996
10460
  metrics_exports as metrics,
9997
10461
  normalizePayload,
10462
+ parseRuleConfig,
9998
10463
  reinitLogger,
9999
10464
  startSqsLagPoller
10000
10465
  };