@junando/core 0.10.0 → 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 +1483 -5
  2. package/dist/index.js +575 -53
  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,53 @@ 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
+
1133
+ // src/shared/factory-registry.ts
1134
+ var FactoryRegistry = class {
1135
+ _factories = /* @__PURE__ */ new Map();
1136
+ _default = () => {
1137
+ throw new Error(`No factory registered and no default available`);
1138
+ };
1139
+ /**
1140
+ * Register a factory for a given key.
1141
+ * Overwrites any existing registration for that key.
1142
+ */
1143
+ register(key, factory) {
1144
+ this._factories.set(key, factory);
1145
+ }
1146
+ /**
1147
+ * Set the default factory to use when no key matches.
1148
+ */
1149
+ registerDefault(factory) {
1150
+ this._default = factory;
1151
+ }
1152
+ /**
1153
+ * Resolve the factory for a given key.
1154
+ * Returns the default if no specific factory is registered for that key.
1155
+ */
1156
+ resolve(key) {
1157
+ const factory = this._factories.get(key);
1158
+ if (factory) {
1159
+ return factory();
1160
+ }
1161
+ return this._default();
1162
+ }
1163
+ /**
1164
+ * Check if a factory is registered for a given key.
1165
+ */
1166
+ has(key) {
1167
+ return this._factories.has(key);
1168
+ }
1169
+ /**
1170
+ * Return all registered keys.
1171
+ */
1172
+ keys() {
1173
+ return Array.from(this._factories.keys());
1174
+ }
1175
+ };
1176
+
1008
1177
  // src/infrastructure/notifier/slack.adapter.ts
1009
1178
  var logger3 = createLogger();
1010
1179
  function sanitizeEndpointPath(endpointPath) {
@@ -1018,7 +1187,7 @@ var SlackNotifier = class {
1018
1187
  }
1019
1188
  botToken;
1020
1189
  channel;
1021
- async send(cluster, analysis) {
1190
+ async send(cluster, analysis, _channel) {
1022
1191
  const payload = analysis ? this.buildAnalysisMessage(cluster, analysis) : this.buildFallbackMessage(cluster);
1023
1192
  try {
1024
1193
  const res = await fetch(SLACK_API_URL, {
@@ -1140,7 +1309,7 @@ LLM analysis unavailable \u2014 manual investigation required.`
1140
1309
  };
1141
1310
  var ConsoleNotifier = class {
1142
1311
  sent = [];
1143
- async send(cluster, analysis) {
1312
+ async send(cluster, analysis, _channel) {
1144
1313
  try {
1145
1314
  this.sent.push({ cluster, analysis });
1146
1315
  logger3.info(
@@ -1297,7 +1466,7 @@ var TeamsNotifier = class {
1297
1466
  // If parsing happens inside the catch block and throws, the original error
1298
1467
  // context (timeout, network failure, etc.) would be lost.
1299
1468
  hostForErrors;
1300
- async send(cluster, analysis) {
1469
+ async send(cluster, analysis, _channel) {
1301
1470
  const card = analysis ? buildAnalysisCard(cluster, analysis) : buildFallbackCard(cluster);
1302
1471
  const payload = buildAdaptiveCardPayload(card);
1303
1472
  const controller = new AbortController();
@@ -1335,15 +1504,350 @@ var TeamsNotifier = class {
1335
1504
  }
1336
1505
  };
1337
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
+
1338
1821
  // src/infrastructure/notifier/factory.ts
1822
+ function buildNotifierRegistry(config) {
1823
+ const registry2 = new FactoryRegistry();
1824
+ registry2.register("teams", () => {
1825
+ if (!config.teamsWebhookUrl) {
1826
+ throw new Error("NOTIFIER_TYPE=teams requires TEAMS_WEBHOOK_URL to be set");
1827
+ }
1828
+ return new TeamsNotifier(config.teamsWebhookUrl);
1829
+ });
1830
+ registry2.register("slack", () => {
1831
+ if (!config.slackBotToken || !config.slackChannel) {
1832
+ throw new Error("NOTIFIER_TYPE=slack requires SLACK_BOT_TOKEN and SLACK_CHANNEL to be set");
1833
+ }
1834
+ return new SlackNotifier(config.slackBotToken, config.slackChannel);
1835
+ });
1836
+ registry2.registerDefault(() => new SlackNotifier("dummy-token", "#alerts"));
1837
+ return registry2;
1838
+ }
1339
1839
  function createNotifier(config) {
1340
- switch (config.notifierType) {
1341
- case "teams":
1342
- return new TeamsNotifier(config.teamsWebhookUrl);
1343
- case "slack":
1344
- default:
1345
- return new SlackNotifier(config.slackBotToken, config.slackChannel);
1346
- }
1840
+ const registry2 = buildNotifierRegistry(config);
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);
1347
1851
  }
1348
1852
 
1349
1853
  // ../../node_modules/.pnpm/@smithy+smithy-client@4.12.13/node_modules/@smithy/smithy-client/dist-es/NoOpLogger.js
@@ -9743,7 +10247,7 @@ var GetParametersCommand = class extends Command.classBuilder().ep(commonParams2
9743
10247
  };
9744
10248
 
9745
10249
  // src/shared/config/index.ts
9746
- import { z as z5 } from "zod";
10250
+ import { z as z6 } from "zod";
9747
10251
  async function loadSecretsFromSSM() {
9748
10252
  const prefix = process.env.SSM_PREFIX;
9749
10253
  if (!prefix) {
@@ -9779,51 +10283,53 @@ async function loadSecretsFromSSM() {
9779
10283
  createLogger().error({ err }, "Failed to load SSM parameters");
9780
10284
  }
9781
10285
  }
9782
- var ConfigSchema = z5.object({
9783
- llmProvider: z5.enum(["gemini", "claude", "openrouter", "qwen"]),
9784
- llmApiKey: z5.string().min(1),
9785
- 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),
9786
10290
  // Notifier selector — defaults to 'slack' for backward compatibility
9787
- notifierType: z5.enum(["slack", "teams"]).default("slack"),
10291
+ notifierType: z6.enum(["slack", "teams"]).default("slack"),
9788
10292
  // Slack fields — optional at schema level; superRefine enforces them conditionally
9789
- slackBotToken: z5.string().startsWith("xoxb-").optional(),
9790
- slackSigningSecret: z5.string().min(1).optional(),
9791
- 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(),
9792
10296
  // Teams field
9793
- teamsWebhookUrl: z5.string().url().optional(),
9794
- 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),
9795
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).
9796
- redisUrl: z5.string().url(),
9797
- sqsQueueUrl: z5.string().url().optional().or(z5.literal("")),
9798
- dedupTtlSeconds: z5.coerce.number().int().positive().default(300),
9799
- clusterWindowMs: z5.coerce.number().int().positive().default(12e4),
9800
- logLevel: z5.enum(["trace", "debug", "info", "warn", "error"]).default("info"),
9801
- nodeEnv: z5.enum(["development", "test", "production"]).default("development"),
9802
- 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) => {
9803
10307
  if (v === void 0) return LLM_FALLBACK_DEFAULTS.Models;
9804
10308
  if (!v) return [];
9805
10309
  return v.split(",").map((s) => s.trim()).filter(Boolean);
9806
10310
  }),
9807
- 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)
9808
10314
  }).superRefine((data, ctx) => {
9809
10315
  if (data.notifierType === "slack") {
9810
10316
  if (!data.slackBotToken) {
9811
10317
  ctx.addIssue({
9812
- code: z5.ZodIssueCode.custom,
10318
+ code: z6.ZodIssueCode.custom,
9813
10319
  path: ["slackBotToken"],
9814
10320
  message: "[notifierType: slack] SLACK_BOT_TOKEN is required and must start with xoxb-"
9815
10321
  });
9816
10322
  }
9817
10323
  if (!data.slackChannel) {
9818
10324
  ctx.addIssue({
9819
- code: z5.ZodIssueCode.custom,
10325
+ code: z6.ZodIssueCode.custom,
9820
10326
  path: ["slackChannel"],
9821
10327
  message: "[notifierType: slack] SLACK_CHANNEL is required and must start with #"
9822
10328
  });
9823
10329
  }
9824
10330
  if (!data.slackSigningSecret) {
9825
10331
  ctx.addIssue({
9826
- code: z5.ZodIssueCode.custom,
10332
+ code: z6.ZodIssueCode.custom,
9827
10333
  path: ["slackSigningSecret"],
9828
10334
  message: "[notifierType: slack] SLACK_SIGNING_SECRET is required (used to verify Slack interactivity callbacks)"
9829
10335
  });
@@ -9832,7 +10338,7 @@ var ConfigSchema = z5.object({
9832
10338
  if (data.notifierType === "teams") {
9833
10339
  if (!data.teamsWebhookUrl) {
9834
10340
  ctx.addIssue({
9835
- code: z5.ZodIssueCode.custom,
10341
+ code: z6.ZodIssueCode.custom,
9836
10342
  path: ["teamsWebhookUrl"],
9837
10343
  message: "[notifierType: teams] TEAMS_WEBHOOK_URL is required"
9838
10344
  });
@@ -9842,14 +10348,14 @@ var ConfigSchema = z5.object({
9842
10348
  parsed = new URL(data.teamsWebhookUrl);
9843
10349
  } catch {
9844
10350
  ctx.addIssue({
9845
- code: z5.ZodIssueCode.custom,
10351
+ code: z6.ZodIssueCode.custom,
9846
10352
  path: ["teamsWebhookUrl"],
9847
10353
  message: "[notifierType: teams] TEAMS_WEBHOOK_URL must be a valid URL"
9848
10354
  });
9849
10355
  }
9850
10356
  if (parsed && !parsed.searchParams.has("api-version")) {
9851
10357
  ctx.addIssue({
9852
- code: z5.ZodIssueCode.custom,
10358
+ code: z6.ZodIssueCode.custom,
9853
10359
  path: ["teamsWebhookUrl"],
9854
10360
  message: "[notifierType: teams] TEAMS_WEBHOOK_URL must include api-version= as a query parameter"
9855
10361
  });
@@ -9876,7 +10382,8 @@ async function loadConfig2() {
9876
10382
  logLevel: process.env["LOG_LEVEL"],
9877
10383
  nodeEnv: process.env["NODE_ENV"],
9878
10384
  llmFallbackModels: process.env["LLM_FALLBACK_MODELS"],
9879
- llmFallbackTimeoutMs: process.env["LLM_FALLBACK_TIMEOUT_MS"]
10385
+ llmFallbackTimeoutMs: process.env["LLM_FALLBACK_TIMEOUT_MS"],
10386
+ rulesConfigPath: process.env["RULES_CONFIG_PATH"]
9880
10387
  });
9881
10388
  if (!result.success) {
9882
10389
  const errorMessages = result.error.issues.map(
@@ -9894,11 +10401,13 @@ export {
9894
10401
  AlertType,
9895
10402
  AlertmanagerPayloadSchema,
9896
10403
  CIRCUIT_BREAKER,
10404
+ ChannelRegistry,
9897
10405
  ClaudeProvider,
9898
10406
  ClusteringService,
9899
10407
  ConsoleNotifier,
9900
10408
  DEDUP_TTL_MS_MULTIPLIER,
9901
10409
  DEV_SERVER_PORT,
10410
+ FactoryRegistry,
9902
10411
  Fingerprint,
9903
10412
  GeminiProvider,
9904
10413
  HOUR_MS,
@@ -9922,8 +10431,18 @@ export {
9922
10431
  RATE_LIMITER,
9923
10432
  REDIS_KEY_PREFIX,
9924
10433
  RedisDeduplicationStore,
10434
+ RoutingNotifier,
10435
+ RuleActionSchema,
10436
+ RuleActionType,
10437
+ RuleConditionSchema,
10438
+ RuleConfigurationSchema,
10439
+ RuleEngine,
10440
+ RuleEvaluationPhase,
10441
+ RuleSchema,
10442
+ RuleSectionSchema,
9925
10443
  SLACK_API_URL,
9926
10444
  SQSAlertQueue,
10445
+ SeverityLevel,
9927
10446
  SlackNotifier,
9928
10447
  TEAMS_WEBHOOK_TIMEOUT_MS,
9929
10448
  TeamsNotifier,
@@ -9931,13 +10450,16 @@ export {
9931
10450
  URGENCY_EMOJI,
9932
10451
  UrgencyLevelSchema,
9933
10452
  WEBHOOK_DEFAULTS,
10453
+ compileCondition,
9934
10454
  createLLMProvider,
9935
10455
  createLogger,
9936
10456
  createNotifier,
10457
+ dispatchActions,
9937
10458
  flushLoki,
9938
10459
  loadConfig2 as loadConfig,
9939
10460
  metrics_exports as metrics,
9940
10461
  normalizePayload,
10462
+ parseRuleConfig,
9941
10463
  reinitLogger,
9942
10464
  startSqsLagPoller
9943
10465
  };