mergen-server 1.4.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.
- package/LICENSE +57 -0
- package/README.md +96 -0
- package/dist/__stubs__/calibration.js +471 -0
- package/dist/__stubs__/causal.js +601 -0
- package/dist/__stubs__/closed-source.js +150 -0
- package/dist/__stubs__/license.js +35 -0
- package/dist/__stubs__/plans.js +197 -0
- package/dist/app.js +467 -0
- package/dist/cli.js +250 -0
- package/dist/commands/agent-identity.js +79 -0
- package/dist/commands/gate.js +107 -0
- package/dist/commands/github.js +632 -0
- package/dist/commands/incident.js +644 -0
- package/dist/commands/policy.js +710 -0
- package/dist/commands/scan.js +243 -0
- package/dist/commands/setup.js +1218 -0
- package/dist/commands/shared.js +109 -0
- package/dist/commands/team.js +809 -0
- package/dist/datadog/blame-attribution.js +228 -0
- package/dist/datadog/client.js +159 -0
- package/dist/datadog/compactor.js +169 -0
- package/dist/datadog/fingerprinter.js +34 -0
- package/dist/datadog/incident-state.js +21 -0
- package/dist/datadog/line-matcher.js +56 -0
- package/dist/datadog/memory-store.js +399 -0
- package/dist/datadog/otel-trace.js +67 -0
- package/dist/index.js +505 -0
- package/dist/intelligence/action-risk.js +132 -0
- package/dist/intelligence/activity-feed.js +40 -0
- package/dist/intelligence/agent-identity.js +115 -0
- package/dist/intelligence/agent-pipeline.js +329 -0
- package/dist/intelligence/agent-profiles.js +81 -0
- package/dist/intelligence/ai-commit.js +16 -0
- package/dist/intelligence/approval-analytics.js +42 -0
- package/dist/intelligence/approval-events.js +5 -0
- package/dist/intelligence/arch-boundaries.js +94 -0
- package/dist/intelligence/arch-graph.js +117 -0
- package/dist/intelligence/autonomy.js +397 -0
- package/dist/intelligence/azure-broker.js +57 -0
- package/dist/intelligence/azure-proxy-sessions.js +49 -0
- package/dist/intelligence/baseline.js +1 -0
- package/dist/intelligence/behavior-baseline.js +134 -0
- package/dist/intelligence/billing.js +1 -0
- package/dist/intelligence/blast-radius.js +224 -0
- package/dist/intelligence/bypass.js +157 -0
- package/dist/intelligence/calibration-classifier.js +77 -0
- package/dist/intelligence/calibration-git-sync.js +74 -0
- package/dist/intelligence/calibration.js +1 -0
- package/dist/intelligence/cascade-detector.js +58 -0
- package/dist/intelligence/case-study-generator.js +126 -0
- package/dist/intelligence/causal-graph.js +126 -0
- package/dist/intelligence/causal.js +1 -0
- package/dist/intelligence/change-risk.js +102 -0
- package/dist/intelligence/compliance-report.js +134 -0
- package/dist/intelligence/confidence-report.js +85 -0
- package/dist/intelligence/corpus-to-policy.js +140 -0
- package/dist/intelligence/credential-broker.js +112 -0
- package/dist/intelligence/credential-misuse.js +39 -0
- package/dist/intelligence/debug-sessions.js +108 -0
- package/dist/intelligence/decision-escalation.js +36 -0
- package/dist/intelligence/decision-memory.js +47 -0
- package/dist/intelligence/decision-similarity.js +85 -0
- package/dist/intelligence/default-safety-tests.js +53 -0
- package/dist/intelligence/degradation-watcher.js +108 -0
- package/dist/intelligence/detector-plugins.js +70 -0
- package/dist/intelligence/detectors.js +1 -0
- package/dist/intelligence/device-auth.js +138 -0
- package/dist/intelligence/diff-size.js +42 -0
- package/dist/intelligence/ebpf-verify.js +45 -0
- package/dist/intelligence/enterprise-policy-engine.js +1122 -0
- package/dist/intelligence/error-fingerprint.js +1 -0
- package/dist/intelligence/execution-gate.js +100 -0
- package/dist/intelligence/execution-mode.js +22 -0
- package/dist/intelligence/gate-analytics.js +256 -0
- package/dist/intelligence/gate-decision.js +64 -0
- package/dist/intelligence/gcp-broker.js +50 -0
- package/dist/intelligence/git-adr-sync.js +207 -0
- package/dist/intelligence/hitl-hold.js +460 -0
- package/dist/intelligence/hypothesis-history.js +93 -0
- package/dist/intelligence/idp-identity.js +83 -0
- package/dist/intelligence/impl-critic.js +216 -0
- package/dist/intelligence/incident-autopilot.js +622 -0
- package/dist/intelligence/incident-replay.js +162 -0
- package/dist/intelligence/incident-result-cache.js +33 -0
- package/dist/intelligence/incident-similarity.js +58 -0
- package/dist/intelligence/infra-detectors.js +312 -0
- package/dist/intelligence/license.js +185 -0
- package/dist/intelligence/llm-spokesperson.js +62 -0
- package/dist/intelligence/mcp-prompts.js +320 -0
- package/dist/intelligence/mcp-resources.js +171 -0
- package/dist/intelligence/normalize.js +108 -0
- package/dist/intelligence/notifications.js +83 -0
- package/dist/intelligence/oidc-issuer.js +87 -0
- package/dist/intelligence/override-corpus.js +597 -0
- package/dist/intelligence/planning-gate.js +84 -0
- package/dist/intelligence/plans.js +223 -0
- package/dist/intelligence/platt-scaling.js +164 -0
- package/dist/intelligence/policy-proposals.js +84 -0
- package/dist/intelligence/policy-suggester.js +161 -0
- package/dist/intelligence/policy-sync.js +103 -0
- package/dist/intelligence/policy-test-runner.js +35 -0
- package/dist/intelligence/postmortem-parser.js +181 -0
- package/dist/intelligence/postmortem-retrieval.js +224 -0
- package/dist/intelligence/postmortem-store.js +596 -0
- package/dist/intelligence/pr-commenter.js +81 -0
- package/dist/intelligence/pr-policy-gate.js +38 -0
- package/dist/intelligence/pr-shadow-analyzer.js +229 -0
- package/dist/intelligence/process-tree.js +108 -0
- package/dist/intelligence/prompts.js +1 -0
- package/dist/intelligence/replay.js +38 -0
- package/dist/intelligence/repro-steps.js +1 -0
- package/dist/intelligence/resource-extractors.js +0 -0
- package/dist/intelligence/resource-file-context.js +138 -0
- package/dist/intelligence/risk-score.js +22 -0
- package/dist/intelligence/rollback.js +137 -0
- package/dist/intelligence/runbook-updater.js +143 -0
- package/dist/intelligence/sandbox.js +194 -0
- package/dist/intelligence/script-trust.js +104 -0
- package/dist/intelligence/session-metrics.js +67 -0
- package/dist/intelligence/session-threat-tracker.js +231 -0
- package/dist/intelligence/shadow-digest-cron.js +81 -0
- package/dist/intelligence/shadow-log.js +284 -0
- package/dist/intelligence/shell-ast.js +145 -0
- package/dist/intelligence/siem-forward.js +104 -0
- package/dist/intelligence/slack-digest.js +151 -0
- package/dist/intelligence/slack-override-loop.js +217 -0
- package/dist/intelligence/slack-routing.js +67 -0
- package/dist/intelligence/slack.js +900 -0
- package/dist/intelligence/sql-ast.js +31 -0
- package/dist/intelligence/team.js +1 -0
- package/dist/intelligence/telemetry.js +76 -0
- package/dist/intelligence/threshold-optimizer.js +75 -0
- package/dist/intelligence/token-budget.js +66 -0
- package/dist/intelligence/tool-guard.js +708 -0
- package/dist/intelligence/tool-manifest 2.js +74 -0
- package/dist/intelligence/tool-manifest.js +125 -0
- package/dist/intelligence/tools-analysis.js +857 -0
- package/dist/intelligence/tools-arch.js +152 -0
- package/dist/intelligence/tools-autonomy.js +495 -0
- package/dist/intelligence/tools-blast-radius.js +140 -0
- package/dist/intelligence/tools-browser.js +431 -0
- package/dist/intelligence/tools-change-timeline.js +154 -0
- package/dist/intelligence/tools-credentials.js +117 -0
- package/dist/intelligence/tools-datadog.js +233 -0
- package/dist/intelligence/tools-debug-sessions.js +209 -0
- package/dist/intelligence/tools-discovery.js +284 -0
- package/dist/intelligence/tools-infra.js +612 -0
- package/dist/intelligence/tools-intent.js +128 -0
- package/dist/intelligence/tools-memory.js +514 -0
- package/dist/intelligence/tools-runbook.js +951 -0
- package/dist/intelligence/tools-sessions.js +107 -0
- package/dist/intelligence/tools-state.js +232 -0
- package/dist/intelligence/tools-utility.js +430 -0
- package/dist/intelligence/tools-validate.js +215 -0
- package/dist/intelligence/tools.js +1 -0
- package/dist/intelligence/trace-context.js +11 -0
- package/dist/intelligence/unclassified-clusters.js +87 -0
- package/dist/intelligence/usage.js +195 -0
- package/dist/routes/active-authors.js +63 -0
- package/dist/routes/activity-feed.js +21 -0
- package/dist/routes/adr.js +117 -0
- package/dist/routes/agent-activity.js +184 -0
- package/dist/routes/agent-blunders.js +163 -0
- package/dist/routes/agents.js +175 -0
- package/dist/routes/api-keys.js +65 -0
- package/dist/routes/arch.js +75 -0
- package/dist/routes/audit-export.js +93 -0
- package/dist/routes/billing-dashboard.js +158 -0
- package/dist/routes/billing-outcome.js +77 -0
- package/dist/routes/calibration.js +305 -0
- package/dist/routes/ci-gate-history.js +20 -0
- package/dist/routes/ci-gate.js +232 -0
- package/dist/routes/ci.js +293 -0
- package/dist/routes/compliance-report.js +24 -0
- package/dist/routes/confidence.js +30 -0
- package/dist/routes/credentials.js +119 -0
- package/dist/routes/dashboard.js +1407 -0
- package/dist/routes/decision-memory.js +42 -0
- package/dist/routes/demo.js +844 -0
- package/dist/routes/device-auth-stub.js +173 -0
- package/dist/routes/explain-why.js +39 -0
- package/dist/routes/gate-analytics.js +213 -0
- package/dist/routes/gate.js +28 -0
- package/dist/routes/github-webhook.js +364 -0
- package/dist/routes/habituation.js +30 -0
- package/dist/routes/health-integrations.js +243 -0
- package/dist/routes/heartbeats.js +45 -0
- package/dist/routes/hitl.js +364 -0
- package/dist/routes/impact-report.js +645 -0
- package/dist/routes/incident-webhook.js +63 -0
- package/dist/routes/incidents.js +366 -0
- package/dist/routes/layers.js +62 -0
- package/dist/routes/license.js +187 -0
- package/dist/routes/oidc-issuer.js +30 -0
- package/dist/routes/onboarding.js +170 -0
- package/dist/routes/otel.js +67 -0
- package/dist/routes/otlp-receiver.js +179 -0
- package/dist/routes/overrides.js +241 -0
- package/dist/routes/pagerduty.js +258 -0
- package/dist/routes/policies.js +809 -0
- package/dist/routes/policy-nl.js +169 -0
- package/dist/routes/postmortem.js +102 -0
- package/dist/routes/pr-shadow.js +47 -0
- package/dist/routes/rbac.js +61 -0
- package/dist/routes/replay.js +17 -0
- package/dist/routes/risk-report.js +164 -0
- package/dist/routes/runbooks.js +125 -0
- package/dist/routes/safety-test.js +174 -0
- package/dist/routes/sdk.js +222 -0
- package/dist/routes/sensor.js +819 -0
- package/dist/routes/sentry.js +140 -0
- package/dist/routes/sessions.js +43 -0
- package/dist/routes/setup-ui.js +594 -0
- package/dist/routes/shadow-report.js +107 -0
- package/dist/routes/slack-routing.js +171 -0
- package/dist/routes/team-usage.js +71 -0
- package/dist/routes/telemetry.js +28 -0
- package/dist/routes/tenants.js +199 -0
- package/dist/routes/tickets.js +167 -0
- package/dist/routes/validate.js +13 -0
- package/dist/routes/war-room.js +113 -0
- package/dist/scripts/check-arch.js +22 -0
- package/dist/seeds/community-corpus.js +176 -0
- package/dist/seeds/corpus.js +1224 -0
- package/dist/sensor/action-ledger.js +327 -0
- package/dist/sensor/adr-store.js +140 -0
- package/dist/sensor/agent-blunder-store.js +370 -0
- package/dist/sensor/agent-context-store.js +225 -0
- package/dist/sensor/agent-memory-store.js +231 -0
- package/dist/sensor/audit-fetch.js +23 -0
- package/dist/sensor/audit-log.js +273 -0
- package/dist/sensor/buffer-schemas.js +225 -0
- package/dist/sensor/buffer.js +599 -0
- package/dist/sensor/bypass-tracker.js +93 -0
- package/dist/sensor/ci-gate-history.js +70 -0
- package/dist/sensor/cloud-auth.js +183 -0
- package/dist/sensor/commit-context-store.js +235 -0
- package/dist/sensor/docker-log-stream.js +164 -0
- package/dist/sensor/docker-monitor.js +122 -0
- package/dist/sensor/extended-buffer.js +46 -0
- package/dist/sensor/feedback-token.js +41 -0
- package/dist/sensor/file-lock.js +6 -0
- package/dist/sensor/fs-watcher.js +106 -0
- package/dist/sensor/gate-heartbeat.js +122 -0
- package/dist/sensor/git-suspect.js +136 -0
- package/dist/sensor/habituation-store.js +77 -0
- package/dist/sensor/heartbeat-monitor.js +133 -0
- package/dist/sensor/incident-store.js +340 -0
- package/dist/sensor/infra-normalizer.js +513 -0
- package/dist/sensor/ingest.js +339 -0
- package/dist/sensor/jest-reporter.js +52 -0
- package/dist/sensor/k8s-events.js +132 -0
- package/dist/sensor/layer2-store.js +111 -0
- package/dist/sensor/layer3-store.js +230 -0
- package/dist/sensor/layer4-store.js +147 -0
- package/dist/sensor/logger.js +13 -0
- package/dist/sensor/otel-exporter.js +161 -0
- package/dist/sensor/paths.js +71 -0
- package/dist/sensor/policy-history.js +147 -0
- package/dist/sensor/pr-shadow-store.js +83 -0
- package/dist/sensor/process-watcher.js +162 -0
- package/dist/sensor/rbac.js +92 -0
- package/dist/sensor/redact.js +114 -0
- package/dist/sensor/redis-store.js +191 -0
- package/dist/sensor/route-reachability.js +64 -0
- package/dist/sensor/security-utils.js +18 -0
- package/dist/sensor/service-graph.js +170 -0
- package/dist/sensor/service-topology.js +227 -0
- package/dist/sensor/session-history.js +74 -0
- package/dist/sensor/session-persist.js +38 -0
- package/dist/sensor/shadow-promote.js +122 -0
- package/dist/sensor/sourcemap.js +281 -0
- package/dist/sensor/sqlite-store.js +205 -0
- package/dist/sensor/sso.js +164 -0
- package/dist/sensor/vitest-reporter.js +65 -0
- package/dist/sensor/watcher.js +48 -0
- package/dist/storage/interfaces.js +0 -0
- package/dist/storage/pg/pg-action-ledger.js +138 -0
- package/dist/storage/pg/pg-approval-store.js +107 -0
- package/dist/storage/pg/pg-blunder-store.js +193 -0
- package/dist/storage/pg/pg-ci-gate-history.js +83 -0
- package/dist/storage/pg/pg-client.js +24 -0
- package/dist/storage/pg/pg-event-store.js +63 -0
- package/dist/storage/pg/pg-incident-store.js +190 -0
- package/dist/storage/pg/pg-migrations.js +26 -0
- package/dist/storage/pg/pg-override-corpus.js +447 -0
- package/dist/storage/pg/pg-shadow-log.js +129 -0
- package/dist/storage/sqlite/sqlite-action-ledger.js +18 -0
- package/dist/storage/sqlite/sqlite-approval-store.js +37 -0
- package/dist/storage/sqlite/sqlite-blunder-store.js +27 -0
- package/dist/storage/sqlite/sqlite-ci-gate-history.js +19 -0
- package/dist/storage/sqlite/sqlite-event-store.js +29 -0
- package/dist/storage/sqlite/sqlite-incident-store.js +34 -0
- package/dist/storage/sqlite/sqlite-override-corpus.js +75 -0
- package/dist/storage/sqlite/sqlite-shadow-log.js +36 -0
- package/dist/storage/store-factory.js +55 -0
- package/dist/storage/store-registry.js +31 -0
- package/dist/update-checker.js +107 -0
- package/dist/workers/autopilot-worker.js +28 -0
- package/dist/workers/notification-worker.js +28 -0
- package/dist/workers/queues.js +58 -0
- package/dist/workers/validation-worker.js +24 -0
- package/dist/workers/worker-registry.js +18 -0
- package/package.json +123 -0
- package/sdk/mergen-inject.js +260 -0
- package/sdk/node.js +313 -0
- package/sdk/vite-plugin.ts +57 -0
- package/sdk/webpack-plugin.js +106 -0
|
@@ -0,0 +1,900 @@
|
|
|
1
|
+
import https from "https";
|
|
2
|
+
import crypto from "crypto";
|
|
3
|
+
import { memoryStore } from "../datadog/memory-store.js";
|
|
4
|
+
import { recordVerdict } from "./calibration.js";
|
|
5
|
+
import { getRoutingForService } from "./slack-routing.js";
|
|
6
|
+
import { generateFeedbackToken } from "../sensor/feedback-token.js";
|
|
7
|
+
import { approveExecution, denyExecution } from "./execution-gate.js";
|
|
8
|
+
import { executeRemediation } from "./autonomy.js";
|
|
9
|
+
import { updateShadowReasonByPid } from "./shadow-log.js";
|
|
10
|
+
import { getStores } from "../storage/store-registry.js";
|
|
11
|
+
import logger from "../sensor/logger.js";
|
|
12
|
+
const WEBHOOK = process.env.MERGEN_SLACK_WEBHOOK ?? "";
|
|
13
|
+
const BOT_TOKEN = process.env.MERGEN_SLACK_BOT_TOKEN ?? "";
|
|
14
|
+
const SLACK_CHANNEL = process.env.MERGEN_SLACK_CHANNEL ?? "";
|
|
15
|
+
const _threadByPid = /* @__PURE__ */ new Map();
|
|
16
|
+
function getThread(pid) {
|
|
17
|
+
return _threadByPid.get(pid);
|
|
18
|
+
}
|
|
19
|
+
async function _slackApiGetOnce(path) {
|
|
20
|
+
if (!BOT_TOKEN) return null;
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
try {
|
|
23
|
+
const req = https.request(
|
|
24
|
+
{
|
|
25
|
+
hostname: "slack.com",
|
|
26
|
+
path,
|
|
27
|
+
method: "GET",
|
|
28
|
+
headers: { "Authorization": `Bearer ${BOT_TOKEN}` }
|
|
29
|
+
},
|
|
30
|
+
(res) => {
|
|
31
|
+
let data = "";
|
|
32
|
+
res.on("data", (chunk) => {
|
|
33
|
+
data += chunk.toString();
|
|
34
|
+
});
|
|
35
|
+
res.on("end", () => {
|
|
36
|
+
try {
|
|
37
|
+
resolve(JSON.parse(data));
|
|
38
|
+
} catch {
|
|
39
|
+
resolve(null);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
req.on("error", (err) => {
|
|
45
|
+
logger.warn({ err, path }, "slack: GET request failed");
|
|
46
|
+
resolve(null);
|
|
47
|
+
});
|
|
48
|
+
req.end();
|
|
49
|
+
} catch (err) {
|
|
50
|
+
logger.warn({ err, path }, "slack: GET request failed");
|
|
51
|
+
resolve(null);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
async function _slackApiGet(path, maxAttempts = 3) {
|
|
56
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
57
|
+
const result = await _slackApiGetOnce(path);
|
|
58
|
+
if (!result || result.ok !== false) return result;
|
|
59
|
+
if (result.error && SLACK_NON_RETRYABLE.has(result.error)) {
|
|
60
|
+
logger.warn({ path, error: result.error }, "slack: permanent GET error \u2014 not retrying");
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
if (attempt < maxAttempts) {
|
|
64
|
+
await new Promise((r) => setTimeout(r, 500 * attempt));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
async function fetchSlackThread(threadUrl) {
|
|
70
|
+
if (!BOT_TOKEN) return null;
|
|
71
|
+
const match = threadUrl.match(/\/archives\/([A-Z0-9]+)\/p(\d{16})/);
|
|
72
|
+
if (!match) {
|
|
73
|
+
logger.warn({ threadUrl }, "slack: could not parse thread URL");
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const channel = match[1];
|
|
77
|
+
const rawTs = match[2];
|
|
78
|
+
const ts = `${rawTs.slice(0, 10)}.${rawTs.slice(10)}`;
|
|
79
|
+
const allMessages = [];
|
|
80
|
+
let cursor;
|
|
81
|
+
const MAX_PAGES = 5;
|
|
82
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
83
|
+
const qs = new URLSearchParams({ channel, ts, limit: "100" });
|
|
84
|
+
if (cursor) qs.set("cursor", cursor);
|
|
85
|
+
const result = await _slackApiGet(`/api/conversations.replies?${qs.toString()}`);
|
|
86
|
+
if (!result?.ok || !Array.isArray(result.messages)) {
|
|
87
|
+
logger.warn({ channel, ts, error: result?.error, page }, "slack: conversations.replies failed");
|
|
88
|
+
if (page === 0) return null;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
allMessages.push(...result.messages);
|
|
92
|
+
if (!result.has_more || !result.response_metadata?.next_cursor) break;
|
|
93
|
+
cursor = result.response_metadata.next_cursor;
|
|
94
|
+
}
|
|
95
|
+
const lines = allMessages.map((msg) => {
|
|
96
|
+
const time = msg.ts ? new Date(parseFloat(msg.ts) * 1e3).toISOString().slice(11, 16) : "??:??";
|
|
97
|
+
const user = msg.username ?? msg.bot_profile?.name ?? msg.user ?? "unknown";
|
|
98
|
+
const text = (msg.text ?? "").slice(0, 500);
|
|
99
|
+
return `[${time}] @${user}: ${text}`;
|
|
100
|
+
});
|
|
101
|
+
return lines.join("\n");
|
|
102
|
+
}
|
|
103
|
+
const _channelIdCache = /* @__PURE__ */ new Map();
|
|
104
|
+
async function _resolveChannelId(nameOrId) {
|
|
105
|
+
if (!BOT_TOKEN) return null;
|
|
106
|
+
if (/^[CG][A-Z0-9]{6,}$/i.test(nameOrId)) return nameOrId;
|
|
107
|
+
const clean = nameOrId.replace(/^#/, "").toLowerCase();
|
|
108
|
+
if (_channelIdCache.has(clean)) return _channelIdCache.get(clean);
|
|
109
|
+
let cursor;
|
|
110
|
+
for (let page = 0; page < 3; page++) {
|
|
111
|
+
const qs = new URLSearchParams({
|
|
112
|
+
limit: "200",
|
|
113
|
+
types: "public_channel,private_channel",
|
|
114
|
+
exclude_archived: "true",
|
|
115
|
+
...cursor ? { cursor } : {}
|
|
116
|
+
});
|
|
117
|
+
const result = await _slackApiGet(`/api/conversations.list?${qs.toString()}`);
|
|
118
|
+
if (!result?.ok || !Array.isArray(result.channels)) break;
|
|
119
|
+
for (const ch of result.channels) {
|
|
120
|
+
if (ch.name && ch.id) _channelIdCache.set(ch.name.toLowerCase(), ch.id);
|
|
121
|
+
}
|
|
122
|
+
if (!result.response_metadata?.next_cursor) break;
|
|
123
|
+
cursor = result.response_metadata.next_cursor;
|
|
124
|
+
}
|
|
125
|
+
return _channelIdCache.get(clean) ?? null;
|
|
126
|
+
}
|
|
127
|
+
async function fetchIncidentChannelContext(firedAt, windowMs = 20 * 60 * 1e3) {
|
|
128
|
+
if (!BOT_TOKEN || !SLACK_CHANNEL) return null;
|
|
129
|
+
const channelId = await _resolveChannelId(SLACK_CHANNEL);
|
|
130
|
+
if (!channelId) {
|
|
131
|
+
logger.warn({ channel: SLACK_CHANNEL }, "slack: could not resolve channel ID for context fetch");
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const oldest = String((firedAt - 5 * 60 * 1e3) / 1e3);
|
|
135
|
+
const latest = String((firedAt + windowMs) / 1e3);
|
|
136
|
+
const qs = new URLSearchParams({ channel: channelId, oldest, latest, limit: "50", inclusive: "true" });
|
|
137
|
+
const result = await _slackApiGet(`/api/conversations.history?${qs.toString()}`);
|
|
138
|
+
if (!result?.ok || !Array.isArray(result.messages) || result.messages.length === 0) {
|
|
139
|
+
if (result?.error) logger.debug({ error: result.error, channel: channelId }, "slack: conversations.history returned error");
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
const lines = result.messages.map((msg) => {
|
|
143
|
+
const time = msg.ts ? new Date(parseFloat(msg.ts) * 1e3).toISOString().slice(11, 16) : "??:??";
|
|
144
|
+
const user = msg.username ?? msg.bot_profile?.name ?? msg.user ?? "unknown";
|
|
145
|
+
const text = (msg.text ?? "").slice(0, 500);
|
|
146
|
+
return `[${time}] @${user}: ${text}`;
|
|
147
|
+
});
|
|
148
|
+
return lines.length > 0 ? lines.join("\n") : null;
|
|
149
|
+
}
|
|
150
|
+
const SLACK_NON_RETRYABLE = /* @__PURE__ */ new Set([
|
|
151
|
+
"ratelimited",
|
|
152
|
+
// handled separately (exponential back-off needed)
|
|
153
|
+
"channel_not_found",
|
|
154
|
+
"not_in_channel",
|
|
155
|
+
"invalid_auth",
|
|
156
|
+
"token_revoked",
|
|
157
|
+
"account_inactive",
|
|
158
|
+
"no_permission"
|
|
159
|
+
]);
|
|
160
|
+
async function _slackApiOnce(path, payload) {
|
|
161
|
+
if (!BOT_TOKEN) return null;
|
|
162
|
+
const body = JSON.stringify(payload);
|
|
163
|
+
return new Promise((resolve) => {
|
|
164
|
+
try {
|
|
165
|
+
const req = https.request(
|
|
166
|
+
{
|
|
167
|
+
hostname: "slack.com",
|
|
168
|
+
path,
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: {
|
|
171
|
+
"Content-Type": "application/json",
|
|
172
|
+
"Authorization": `Bearer ${BOT_TOKEN}`,
|
|
173
|
+
"Content-Length": Buffer.byteLength(body)
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
(res) => {
|
|
177
|
+
let data = "";
|
|
178
|
+
res.on("data", (chunk) => {
|
|
179
|
+
data += chunk.toString();
|
|
180
|
+
});
|
|
181
|
+
res.on("end", () => {
|
|
182
|
+
try {
|
|
183
|
+
const parsed = JSON.parse(data);
|
|
184
|
+
if (!parsed.ok) {
|
|
185
|
+
logger.warn({ error: parsed.error, path }, "slack: Web API call failed");
|
|
186
|
+
}
|
|
187
|
+
resolve(parsed);
|
|
188
|
+
} catch {
|
|
189
|
+
resolve(null);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
);
|
|
194
|
+
req.on("error", (err) => {
|
|
195
|
+
logger.warn({ err, path }, "slack: Web API request failed");
|
|
196
|
+
resolve(null);
|
|
197
|
+
});
|
|
198
|
+
req.write(body);
|
|
199
|
+
req.end();
|
|
200
|
+
} catch (err) {
|
|
201
|
+
logger.warn({ err, path }, "slack: failed to post via Web API");
|
|
202
|
+
resolve(null);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
async function _slackApi(path, payload, maxAttempts = 3) {
|
|
207
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
208
|
+
const result = await _slackApiOnce(path, payload);
|
|
209
|
+
if (result?.ok) return result;
|
|
210
|
+
if (result?.error && SLACK_NON_RETRYABLE.has(result.error)) {
|
|
211
|
+
logger.warn({ path, error: result.error }, "slack: permanent error \u2014 not retrying");
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
if (attempt < maxAttempts) {
|
|
215
|
+
const delayMs = 500 * attempt;
|
|
216
|
+
logger.debug({ path, attempt, delayMs }, "slack: retrying after transient failure");
|
|
217
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
async function _postWebApi(channel, payload, threadTs) {
|
|
223
|
+
const result = await _slackApi("/api/chat.postMessage", { channel, thread_ts: threadTs, ...payload });
|
|
224
|
+
return result?.ok && result.ts ? { ts: result.ts } : null;
|
|
225
|
+
}
|
|
226
|
+
async function _openOverrideModal(triggerId, pid) {
|
|
227
|
+
const view = {
|
|
228
|
+
type: "modal",
|
|
229
|
+
callback_id: "override_modal",
|
|
230
|
+
private_metadata: pid,
|
|
231
|
+
title: { type: "plain_text", text: "Override Mergen" },
|
|
232
|
+
submit: { type: "plain_text", text: "Submit Override" },
|
|
233
|
+
close: { type: "plain_text", text: "Cancel" },
|
|
234
|
+
blocks: [
|
|
235
|
+
{
|
|
236
|
+
type: "input",
|
|
237
|
+
block_id: "reason_block",
|
|
238
|
+
element: {
|
|
239
|
+
type: "static_select",
|
|
240
|
+
action_id: "reason_select",
|
|
241
|
+
placeholder: { type: "plain_text", text: "Select a reason..." },
|
|
242
|
+
options: [
|
|
243
|
+
{ text: { type: "plain_text", text: "Too risky for production" }, value: "too_risky" },
|
|
244
|
+
{ text: { type: "plain_text", text: "Fix is incorrect" }, value: "fix_incorrect" },
|
|
245
|
+
{ text: { type: "plain_text", text: "False positive diagnosis" }, value: "false_positive" },
|
|
246
|
+
{ text: { type: "plain_text", text: "Other / Need manual review" }, value: "other" }
|
|
247
|
+
]
|
|
248
|
+
},
|
|
249
|
+
label: { type: "plain_text", text: "Why are you overriding this action?" }
|
|
250
|
+
}
|
|
251
|
+
]
|
|
252
|
+
};
|
|
253
|
+
await _slackApi("/api/views.open", { trigger_id: triggerId, view });
|
|
254
|
+
}
|
|
255
|
+
async function _openEditCommandModal(triggerId, pid, command) {
|
|
256
|
+
const view = {
|
|
257
|
+
type: "modal",
|
|
258
|
+
callback_id: "edit_command_modal",
|
|
259
|
+
private_metadata: pid,
|
|
260
|
+
title: { type: "plain_text", text: "Edit and Execute" },
|
|
261
|
+
submit: { type: "plain_text", text: "Approve & Run" },
|
|
262
|
+
close: { type: "plain_text", text: "Cancel" },
|
|
263
|
+
blocks: [
|
|
264
|
+
{
|
|
265
|
+
type: "section",
|
|
266
|
+
text: {
|
|
267
|
+
type: "mrkdwn",
|
|
268
|
+
text: "Modify the proposed command below before executing it on the target system."
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
type: "input",
|
|
273
|
+
block_id: "command_block",
|
|
274
|
+
element: {
|
|
275
|
+
type: "plain_text_input",
|
|
276
|
+
action_id: "command_input",
|
|
277
|
+
initial_value: command,
|
|
278
|
+
multiline: true
|
|
279
|
+
},
|
|
280
|
+
label: { type: "plain_text", text: "Command to Execute" }
|
|
281
|
+
}
|
|
282
|
+
]
|
|
283
|
+
};
|
|
284
|
+
await _slackApi("/api/views.open", { trigger_id: triggerId, view });
|
|
285
|
+
}
|
|
286
|
+
async function postThreadReply(pid, text) {
|
|
287
|
+
const thread = _threadByPid.get(pid);
|
|
288
|
+
if (!thread) {
|
|
289
|
+
logger.debug({ pid }, "slack: no thread found for pid \u2014 cannot reply");
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
const result = await _postWebApi(thread.channel, { text }, thread.ts);
|
|
293
|
+
return result !== null;
|
|
294
|
+
}
|
|
295
|
+
async function postSimpleWebhookNotification(service, text) {
|
|
296
|
+
const webhook = _webhookForService(service);
|
|
297
|
+
if (!webhook) {
|
|
298
|
+
logger.debug({ service }, "slack: no webhook URL configured for service \u2014 cannot send webhook notification");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const payload = JSON.stringify({
|
|
302
|
+
text,
|
|
303
|
+
username: "Mergen",
|
|
304
|
+
icon_emoji: ":robot_face:"
|
|
305
|
+
});
|
|
306
|
+
await _postWebhook(payload, webhook);
|
|
307
|
+
}
|
|
308
|
+
async function postThreadBlocks(pid, blocks) {
|
|
309
|
+
const thread = _threadByPid.get(pid);
|
|
310
|
+
if (!thread) {
|
|
311
|
+
logger.debug({ pid }, "slack: no thread for blocks post");
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
const result = await _postWebApi(thread.channel, { blocks }, thread.ts);
|
|
315
|
+
return result !== null;
|
|
316
|
+
}
|
|
317
|
+
async function postApprovalRequest(pid, command, tier, remediationConfidence, blastRadius) {
|
|
318
|
+
const pct = Math.round(remediationConfidence * 100);
|
|
319
|
+
const tierBadge = tier === "restart" ? "\u{1F504} restart" : tier === "deploy" ? "\u{1F680} deploy" : "\u26A1 full";
|
|
320
|
+
const blastLines = [];
|
|
321
|
+
if (blastRadius) {
|
|
322
|
+
const dt = blastRadius.estimatedDowntimeMs !== null ? `~${Math.round(blastRadius.estimatedDowntimeMs / 1e3)}s` : "unknown";
|
|
323
|
+
const rev = blastRadius.reversible ? "\u2705 Yes" : "\u274C No";
|
|
324
|
+
const data = blastRadius.dataAtRisk ? "\u26A0\uFE0F Yes" : "\u274C No";
|
|
325
|
+
blastLines.push(
|
|
326
|
+
``,
|
|
327
|
+
`*Blast Radius* _(${blastRadius.modelConfidence} model confidence)_`,
|
|
328
|
+
`\u2022 Scope: \`${blastRadius.scope}\`${blastRadius.affectedResources.length > 0 ? ` \xB7 Resource: \`${blastRadius.affectedResources[0]}\`` : ""}`,
|
|
329
|
+
`\u2022 Downtime: ${dt} \xB7 Recoverable: ${rev}`,
|
|
330
|
+
blastRadius.rollbackCommand ? `\u2022 Rollback: \`${blastRadius.rollbackCommand}\`${blastRadius.rollbackLatencyMs ? ` (~${Math.round(blastRadius.rollbackLatencyMs / 1e3)}s)` : ""}` : `\u2022 Rollback: _not available_`,
|
|
331
|
+
`\u2022 Data at risk: ${data}`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return postThreadBlocks(pid, [
|
|
335
|
+
{
|
|
336
|
+
type: "section",
|
|
337
|
+
text: {
|
|
338
|
+
type: "mrkdwn",
|
|
339
|
+
text: [
|
|
340
|
+
`\u{1F510} *Mergen Autopilot \u2014 Approval Required*`,
|
|
341
|
+
`Risk tier: *${tierBadge}* \xB7 Remediation confidence: *${pct}%*`,
|
|
342
|
+
``,
|
|
343
|
+
`*Command to execute:*`,
|
|
344
|
+
`\`\`\`${command}\`\`\``,
|
|
345
|
+
...blastLines,
|
|
346
|
+
``,
|
|
347
|
+
`_Approval window: 15 min. No action = auto-expire._`
|
|
348
|
+
].join("\n")
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
type: "actions",
|
|
353
|
+
elements: [
|
|
354
|
+
{
|
|
355
|
+
type: "button",
|
|
356
|
+
action_id: `execute_fix_${pid}`,
|
|
357
|
+
text: { type: "plain_text", text: "\u2705 Execute", emoji: true },
|
|
358
|
+
value: JSON.stringify({ pid, command }),
|
|
359
|
+
style: "primary"
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
type: "button",
|
|
363
|
+
action_id: `edit_fix_trigger_${pid}`,
|
|
364
|
+
text: { type: "plain_text", text: "\u270F\uFE0F Edit & Execute", emoji: true },
|
|
365
|
+
value: JSON.stringify({ pid, command })
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
type: "button",
|
|
369
|
+
action_id: `deny_fix_${pid}`,
|
|
370
|
+
text: { type: "plain_text", text: "\u274C Deny", emoji: true },
|
|
371
|
+
value: JSON.stringify({ pid }),
|
|
372
|
+
style: "danger"
|
|
373
|
+
}
|
|
374
|
+
]
|
|
375
|
+
}
|
|
376
|
+
]);
|
|
377
|
+
}
|
|
378
|
+
const SIGNING_SECRET = process.env.MERGEN_SLACK_SIGNING_SECRET ?? "";
|
|
379
|
+
const COOLDOWN = 5 * 60 * 1e3;
|
|
380
|
+
const MIN_CONFIDENCE = 0.75;
|
|
381
|
+
const isInteractive = !!BOT_TOKEN && !!SIGNING_SECRET;
|
|
382
|
+
const _lastAlertAt = /* @__PURE__ */ new Map();
|
|
383
|
+
function _webhookForService(service) {
|
|
384
|
+
if (service) {
|
|
385
|
+
const rule = getRoutingForService(service);
|
|
386
|
+
if (rule?.webhook) return rule.webhook;
|
|
387
|
+
}
|
|
388
|
+
return WEBHOOK;
|
|
389
|
+
}
|
|
390
|
+
function _minConfidenceForService(service) {
|
|
391
|
+
if (service) {
|
|
392
|
+
const rule = getRoutingForService(service);
|
|
393
|
+
if (typeof rule?.minConfidence === "number") return rule.minConfidence;
|
|
394
|
+
}
|
|
395
|
+
return MIN_CONFIDENCE;
|
|
396
|
+
}
|
|
397
|
+
function shouldAlert(hyp, service) {
|
|
398
|
+
const webhook = _webhookForService(service);
|
|
399
|
+
if (!webhook) return false;
|
|
400
|
+
if ((hyp.confidenceScore ?? 0) < _minConfidenceForService(service)) return false;
|
|
401
|
+
const last = _lastAlertAt.get(hyp.tag) ?? 0;
|
|
402
|
+
return Date.now() - last > COOLDOWN;
|
|
403
|
+
}
|
|
404
|
+
async function postSlackAlert(hyp, context = {}) {
|
|
405
|
+
const webhook = _webhookForService(context.service);
|
|
406
|
+
if (!webhook || !shouldAlert(hyp, context.service)) return;
|
|
407
|
+
_lastAlertAt.set(hyp.tag, Date.now());
|
|
408
|
+
const routingRule = context.service ? getRoutingForService(context.service) : null;
|
|
409
|
+
const escalate = routingRule?.escalateAt != null && routingRule.oncallMention && (hyp.confidenceScore ?? 0) >= routingRule.escalateAt;
|
|
410
|
+
const pct = Math.round((hyp.confidenceScore ?? 0) * 100);
|
|
411
|
+
const color = pct >= 85 ? "#d32f2f" : "#f57c00";
|
|
412
|
+
const metaParts = [];
|
|
413
|
+
if (context.sha) metaParts.push(`Commit: \`${context.sha.slice(0, 7)}\``);
|
|
414
|
+
if (context.branch) metaParts.push(`Branch: \`${context.branch}\``);
|
|
415
|
+
if (context.environment) metaParts.push(`Env: \`${context.environment}\``);
|
|
416
|
+
if (context.owners?.length) metaParts.push(`Owners: ${context.owners.join(" ")}`);
|
|
417
|
+
const blocks = [
|
|
418
|
+
{
|
|
419
|
+
type: "header",
|
|
420
|
+
text: { type: "plain_text", text: `\u{1F534} Mergen Causal Alert`, emoji: true }
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
type: "section",
|
|
424
|
+
text: { type: "mrkdwn", text: `*${hyp.summary}*` }
|
|
425
|
+
},
|
|
426
|
+
...metaParts.length ? [{
|
|
427
|
+
type: "section",
|
|
428
|
+
text: { type: "mrkdwn", text: metaParts.join(" | ") }
|
|
429
|
+
}] : [],
|
|
430
|
+
...hyp.fixHint ? [{
|
|
431
|
+
type: "section",
|
|
432
|
+
text: { type: "mrkdwn", text: `\u{1F4A1} *Fix:* ${hyp.fixHint}` }
|
|
433
|
+
}] : [],
|
|
434
|
+
...hyp.evidence?.length ? [{
|
|
435
|
+
type: "section",
|
|
436
|
+
text: {
|
|
437
|
+
type: "mrkdwn",
|
|
438
|
+
text: `*Evidence:*
|
|
439
|
+
${hyp.evidence.slice(0, 3).map((e) => `\u2022 ${e}`).join("\n")}`
|
|
440
|
+
}
|
|
441
|
+
}] : [],
|
|
442
|
+
...escalate && routingRule?.oncallMention ? [{
|
|
443
|
+
type: "section",
|
|
444
|
+
text: { type: "mrkdwn", text: `\u{1F6A8} *Escalation:* ${routingRule.oncallMention} \u2014 confidence above threshold` }
|
|
445
|
+
}] : [],
|
|
446
|
+
{
|
|
447
|
+
type: "context",
|
|
448
|
+
elements: [{ type: "mrkdwn", text: `Detector: \`${hyp.tag}\`${context.service ? ` \xB7 ${context.service}` : ""} \xB7 Mergen` }]
|
|
449
|
+
},
|
|
450
|
+
// Action buttons — feedback + dashboard link
|
|
451
|
+
{
|
|
452
|
+
type: "actions",
|
|
453
|
+
elements: [
|
|
454
|
+
...context.dashboardUrl ? [{
|
|
455
|
+
type: "button",
|
|
456
|
+
text: { type: "plain_text", text: "\u{1F5C2} View Timeline", emoji: true },
|
|
457
|
+
url: context.dashboardUrl,
|
|
458
|
+
style: "primary"
|
|
459
|
+
}] : [],
|
|
460
|
+
// Feedback buttons: interactive if bot token is set, link-based otherwise
|
|
461
|
+
...hyp.pid ? isInteractive ? [
|
|
462
|
+
{
|
|
463
|
+
type: "button",
|
|
464
|
+
action_id: `shadow_approve_${hyp.pid}`,
|
|
465
|
+
text: { type: "plain_text", text: "\u2705 Approve Fix", emoji: true },
|
|
466
|
+
value: JSON.stringify({ pid: hyp.pid, verdict: "would-approve" }),
|
|
467
|
+
style: "primary"
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
type: "button",
|
|
471
|
+
action_id: `shadow_override_${hyp.pid}`,
|
|
472
|
+
text: { type: "plain_text", text: "\u270B Override", emoji: true },
|
|
473
|
+
value: JSON.stringify({ pid: hyp.pid, verdict: "would-override" }),
|
|
474
|
+
style: "danger"
|
|
475
|
+
}
|
|
476
|
+
] : context.dashboardUrl ? [
|
|
477
|
+
{
|
|
478
|
+
type: "button",
|
|
479
|
+
text: { type: "plain_text", text: "\u2705 Approve Fix", emoji: true },
|
|
480
|
+
url: `${context.dashboardUrl}/feedback?pid=${encodeURIComponent(hyp.pid)}&verdict=would-approve`
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
type: "button",
|
|
484
|
+
text: { type: "plain_text", text: "\u270B Override", emoji: true },
|
|
485
|
+
url: `${context.dashboardUrl}/feedback?pid=${encodeURIComponent(hyp.pid)}&verdict=would-override`
|
|
486
|
+
}
|
|
487
|
+
] : [] : []
|
|
488
|
+
]
|
|
489
|
+
}
|
|
490
|
+
];
|
|
491
|
+
const payload = JSON.stringify({ attachments: [{ color, blocks }] });
|
|
492
|
+
return _postWebhook(payload, webhook);
|
|
493
|
+
}
|
|
494
|
+
async function postIncidentAlert(opts) {
|
|
495
|
+
const webhook = _webhookForService(opts.service);
|
|
496
|
+
const channel = getRoutingForService(opts.service)?.channel ?? SLACK_CHANNEL;
|
|
497
|
+
if (!webhook && !(BOT_TOKEN && channel)) return;
|
|
498
|
+
const routingRule = getRoutingForService(opts.service);
|
|
499
|
+
const { alertTitle, service, firedAt, incidentId, pdUrl, blame, blastRadius, dashboardUrl } = opts;
|
|
500
|
+
const ageMin = Math.round((Date.now() - firedAt) / 6e4);
|
|
501
|
+
const ageTxt = ageMin < 2 ? "just now" : `${ageMin}m ago`;
|
|
502
|
+
const blocks = [
|
|
503
|
+
{
|
|
504
|
+
type: "header",
|
|
505
|
+
text: { type: "plain_text", text: `\u{1F6A8} Production Incident \u2014 ${service}`, emoji: true }
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
type: "section",
|
|
509
|
+
text: { type: "mrkdwn", text: `*${alertTitle}*
|
|
510
|
+
Fired *${ageTxt}*${pdUrl ? ` | <${pdUrl}|PagerDuty>` : ""}` }
|
|
511
|
+
}
|
|
512
|
+
];
|
|
513
|
+
if (blame?.topCandidate) {
|
|
514
|
+
const sha8 = blame.topCandidate.sha.slice(0, 8);
|
|
515
|
+
const pct = Math.round(blame.confidence * 100);
|
|
516
|
+
const label = blame.confidenceLabel;
|
|
517
|
+
const color2 = label === "HIGH" ? "\u2705" : label === "MEDIUM" ? "\u26A0\uFE0F" : "\u2753";
|
|
518
|
+
const signals = [blame.signals.timing, blame.signals.shaMatch, blame.signals.fileOverlap].filter((s) => s.available && s.score > 0).map((s) => `\u2022 ${s.detail.slice(0, 80)}`);
|
|
519
|
+
blocks.push({
|
|
520
|
+
type: "section",
|
|
521
|
+
text: {
|
|
522
|
+
type: "mrkdwn",
|
|
523
|
+
text: [
|
|
524
|
+
`*${color2} Causal Attribution \u2014 ${pct}% [${label}]*`,
|
|
525
|
+
`Deploy \`${sha8}\` \u2022 ${blame.topCandidate.environment}`,
|
|
526
|
+
...signals.slice(0, 3),
|
|
527
|
+
blame.lowConfidence ? "_\u26A0\uFE0F Below confidence threshold \u2014 manual investigation recommended_" : ""
|
|
528
|
+
].filter(Boolean).join("\n")
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
} else {
|
|
532
|
+
blocks.push({
|
|
533
|
+
type: "section",
|
|
534
|
+
text: { type: "mrkdwn", text: "_Attribution pending \u2014 no deploy events found. Post deployments to `/deployments` or connect CI._" }
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
if (blastRadius && blastRadius.errorCount > 0) {
|
|
538
|
+
const sessions = blastRadius.affectedSessions > 0 ? `*${blastRadius.affectedSessions}* session${blastRadius.affectedSessions !== 1 ? "s" : ""} affected` : `*${blastRadius.errorCount}* error events`;
|
|
539
|
+
const users = blastRadius.affectedUsers > 0 ? ` (${blastRadius.affectedUsers} authenticated users)` : "";
|
|
540
|
+
const browsers = Object.entries(blastRadius.browserSegments).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([b, n]) => `${b}: ${n}`).join(" | ");
|
|
541
|
+
const durationTxt = blastRadius.durationMs ? ` \u2022 Active *${_fmtDuration(blastRadius.durationMs)}*` : "";
|
|
542
|
+
blocks.push({
|
|
543
|
+
type: "section",
|
|
544
|
+
text: {
|
|
545
|
+
type: "mrkdwn",
|
|
546
|
+
text: [
|
|
547
|
+
`*\u{1F4CA} Blast Radius*`,
|
|
548
|
+
`${sessions}${users}${durationTxt}`,
|
|
549
|
+
browsers ? `Browsers: ${browsers}` : ""
|
|
550
|
+
].filter(Boolean).join("\n")
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
const actions = [];
|
|
555
|
+
if (dashboardUrl) {
|
|
556
|
+
actions.push({
|
|
557
|
+
type: "button",
|
|
558
|
+
text: { type: "plain_text", text: "\u{1F5C2} War Room", emoji: true },
|
|
559
|
+
url: `${dashboardUrl}#war-room`,
|
|
560
|
+
style: "primary"
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
if (incidentId != null && blame?.topCandidate && isInteractive) {
|
|
564
|
+
actions.push(
|
|
565
|
+
{
|
|
566
|
+
type: "button",
|
|
567
|
+
action_id: `inc_attr_correct_${incidentId}`,
|
|
568
|
+
text: { type: "plain_text", text: "\u2705 Attribution Correct", emoji: true },
|
|
569
|
+
value: String(incidentId),
|
|
570
|
+
style: "primary"
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
type: "button",
|
|
574
|
+
action_id: `inc_attr_wrong_${incidentId}`,
|
|
575
|
+
text: { type: "plain_text", text: "\u274C Wrong", emoji: true },
|
|
576
|
+
value: String(incidentId),
|
|
577
|
+
style: "danger"
|
|
578
|
+
}
|
|
579
|
+
);
|
|
580
|
+
} else if (incidentId != null && blame?.topCandidate && dashboardUrl) {
|
|
581
|
+
const tokenCorrect = generateFeedbackToken(incidentId, 1);
|
|
582
|
+
const tokenWrong = generateFeedbackToken(incidentId, 0);
|
|
583
|
+
actions.push(
|
|
584
|
+
{
|
|
585
|
+
type: "button",
|
|
586
|
+
text: { type: "plain_text", text: "\u2705 Attribution Correct", emoji: true },
|
|
587
|
+
url: `${dashboardUrl}/attribution-feedback?id=${incidentId}&correct=1&token=${tokenCorrect}`
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
type: "button",
|
|
591
|
+
text: { type: "plain_text", text: "\u274C Wrong", emoji: true },
|
|
592
|
+
url: `${dashboardUrl}/attribution-feedback?id=${incidentId}&correct=0&token=${tokenWrong}`
|
|
593
|
+
}
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
if (actions.length > 0) {
|
|
597
|
+
blocks.push({ type: "actions", elements: actions });
|
|
598
|
+
}
|
|
599
|
+
if (routingRule?.escalateAt != null && routingRule.oncallMention) {
|
|
600
|
+
blocks.push({
|
|
601
|
+
type: "section",
|
|
602
|
+
text: { type: "mrkdwn", text: `\u{1F6A8} ${routingRule.oncallMention} \u2014 production incident requires attention` }
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
const briefLink = opts.pid ? ` \xB7 <${dashboardUrl ?? "http://127.0.0.1:3000"}/incidents/${opts.pid}/brief|View brief>` : "";
|
|
606
|
+
blocks.push({
|
|
607
|
+
type: "context",
|
|
608
|
+
elements: [{ type: "mrkdwn", text: `Mergen \xB7 ${service} \xB7 <${dashboardUrl ?? "http://127.0.0.1:3000"}/dashboard|Dashboard>${briefLink}` }]
|
|
609
|
+
});
|
|
610
|
+
const color = blame?.confidenceLabel === "HIGH" ? "#d32f2f" : blame?.confidenceLabel === "MEDIUM" ? "#f57c00" : "#64748b";
|
|
611
|
+
if (BOT_TOKEN && channel) {
|
|
612
|
+
const result = await _postWebApi(channel, { attachments: [{ color, blocks }] });
|
|
613
|
+
if (result?.ts && opts.pid) {
|
|
614
|
+
_threadByPid.set(opts.pid, { channel, ts: result.ts });
|
|
615
|
+
logger.info({ pid: opts.pid, ts: result.ts, channel }, "slack: thread registered for autonomous replies");
|
|
616
|
+
}
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
return _postWebhook(JSON.stringify({ attachments: [{ color, blocks }] }), webhook);
|
|
620
|
+
}
|
|
621
|
+
function _fmtDuration(ms) {
|
|
622
|
+
if (ms < 6e4) return `${Math.round(ms / 1e3)}s`;
|
|
623
|
+
if (ms < 36e5) return `${Math.round(ms / 6e4)}m`;
|
|
624
|
+
return `${(ms / 36e5).toFixed(1)}h`;
|
|
625
|
+
}
|
|
626
|
+
function _postWebhook(payload, webhookUrl = WEBHOOK) {
|
|
627
|
+
return new Promise((resolve) => {
|
|
628
|
+
try {
|
|
629
|
+
const url = new URL(webhookUrl);
|
|
630
|
+
const req = https.request(
|
|
631
|
+
{
|
|
632
|
+
hostname: url.hostname,
|
|
633
|
+
path: url.pathname + url.search,
|
|
634
|
+
method: "POST",
|
|
635
|
+
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
|
|
636
|
+
},
|
|
637
|
+
(res) => {
|
|
638
|
+
res.resume();
|
|
639
|
+
resolve();
|
|
640
|
+
}
|
|
641
|
+
);
|
|
642
|
+
req.on("error", (err) => {
|
|
643
|
+
logger.warn({ err }, "slack: webhook post failed");
|
|
644
|
+
resolve();
|
|
645
|
+
});
|
|
646
|
+
req.write(payload);
|
|
647
|
+
req.end();
|
|
648
|
+
} catch (err) {
|
|
649
|
+
logger.warn({ err }, "slack: failed to post");
|
|
650
|
+
resolve();
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
function verifySlackSignature(req) {
|
|
655
|
+
if (!SIGNING_SECRET) return false;
|
|
656
|
+
try {
|
|
657
|
+
const ts = req.headers["x-slack-request-timestamp"];
|
|
658
|
+
const sig = req.headers["x-slack-signature"];
|
|
659
|
+
if (!ts || !sig) return false;
|
|
660
|
+
if (Math.abs(Date.now() / 1e3 - Number(ts)) > 300) return false;
|
|
661
|
+
const body = req.rawBody?.toString("utf8") ?? "";
|
|
662
|
+
const hmac = crypto.createHmac("sha256", SIGNING_SECRET);
|
|
663
|
+
const computed = "v0=" + hmac.update(`v0:${ts}:${body}`).digest("hex");
|
|
664
|
+
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(sig));
|
|
665
|
+
} catch {
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
async function handleSlackActions(req, res) {
|
|
670
|
+
if (!verifySlackSignature(req)) {
|
|
671
|
+
res.status(401).send("Unauthorized");
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
try {
|
|
675
|
+
const body = req.body;
|
|
676
|
+
const payload = JSON.parse(body.payload ?? "{}");
|
|
677
|
+
if (payload.type === "view_submission" && payload.view?.callback_id === "edit_command_modal") {
|
|
678
|
+
const pid = payload.view.private_metadata;
|
|
679
|
+
const values = payload.view.state.values;
|
|
680
|
+
const commandBlock = values["command_block"];
|
|
681
|
+
const commandInput = commandBlock ? commandBlock["command_input"] : null;
|
|
682
|
+
const modifiedCommand = commandInput?.value ?? "";
|
|
683
|
+
if (!modifiedCommand.trim()) {
|
|
684
|
+
res.status(200).json({
|
|
685
|
+
response_action: "errors",
|
|
686
|
+
errors: { command_block: "Command cannot be empty." }
|
|
687
|
+
});
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const userId = payload.user?.id ?? "slack-user";
|
|
691
|
+
const record = approveExecution(pid);
|
|
692
|
+
if (record) {
|
|
693
|
+
logger.info({ pid, originalCommand: record.command, modifiedCommand, userId }, "slack: fix execution approved with edits");
|
|
694
|
+
void postThreadReply(pid, `\u2699\uFE0F _Fix approved with edits by <@${userId}> \u2014 executing\u2026_
|
|
695
|
+
\`${modifiedCommand}\``);
|
|
696
|
+
void executeRemediation(modifiedCommand, { cwd: record.cwd, actor: userId }).then((result) => {
|
|
697
|
+
if (result.blocked) {
|
|
698
|
+
void postThreadReply(pid, `\u{1F6AB} *Fix blocked by safety filter:* ${result.blockReason}`);
|
|
699
|
+
updateShadowReasonByPid(pid, "blocked-by-safety-filter");
|
|
700
|
+
} else if (!result.ok) {
|
|
701
|
+
void postThreadReply(pid, `\u274C *Fix command failed* (exit ${result.exitCode})
|
|
702
|
+
${result.stderr.slice(0, 500)}`);
|
|
703
|
+
updateShadowReasonByPid(pid, "executed-failure");
|
|
704
|
+
} else {
|
|
705
|
+
void postThreadReply(pid, `\u2705 *Fix executed* (${result.durationMs}ms)`);
|
|
706
|
+
updateShadowReasonByPid(pid, "executed");
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
res.status(200).send("");
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (payload.type === "view_submission" && payload.view?.callback_id === "override_modal") {
|
|
714
|
+
const rawMeta = payload.view.private_metadata;
|
|
715
|
+
const values = payload.view.state.values;
|
|
716
|
+
const reasonBlock = values["reason_block"];
|
|
717
|
+
const reasonSelect = reasonBlock ? reasonBlock["reason_select"] : null;
|
|
718
|
+
const selectedReason = reasonSelect?.selected_option?.value ?? "other";
|
|
719
|
+
const REASON_MAP = {
|
|
720
|
+
too_risky: "on-call-discretion",
|
|
721
|
+
fix_incorrect: "wrong-fix",
|
|
722
|
+
false_positive: "wrong-diagnosis",
|
|
723
|
+
other: "other"
|
|
724
|
+
};
|
|
725
|
+
const corpusReason = REASON_MAP[selectedReason] ?? "other";
|
|
726
|
+
if (rawMeta.startsWith("shadow:")) {
|
|
727
|
+
const shadowId = rawMeta.slice(7);
|
|
728
|
+
const result = await getStores().shadowLog.recordShadowVerdict(shadowId, "would-override", {
|
|
729
|
+
overrideReason: corpusReason,
|
|
730
|
+
note: selectedReason,
|
|
731
|
+
actor: "slack-user"
|
|
732
|
+
});
|
|
733
|
+
if (result.found) recordVerdict(result.entry.pid, "wrong", selectedReason);
|
|
734
|
+
logger.info({ shadowId, reason: corpusReason }, "slack: digest override modal submitted");
|
|
735
|
+
} else {
|
|
736
|
+
const pid = rawMeta;
|
|
737
|
+
await getStores().overrides.recordOverride({
|
|
738
|
+
incidentTag: pid,
|
|
739
|
+
proposedCommand: "unknown",
|
|
740
|
+
overrideReason: corpusReason,
|
|
741
|
+
service: "unknown",
|
|
742
|
+
environment: process.env.NODE_ENV ?? "production",
|
|
743
|
+
actor: "slack-user"
|
|
744
|
+
});
|
|
745
|
+
recordVerdict(pid, "wrong", selectedReason);
|
|
746
|
+
logger.info({ pid, reason: corpusReason }, "slack: override persisted to corpus");
|
|
747
|
+
}
|
|
748
|
+
res.status(200).send("");
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
for (const action of payload.actions ?? []) {
|
|
752
|
+
if (action.action_id.startsWith("shadow_approve_")) {
|
|
753
|
+
try {
|
|
754
|
+
const { pid, verdict } = JSON.parse(action.value);
|
|
755
|
+
recordVerdict(pid, "correct");
|
|
756
|
+
logger.info({ pid, verdict }, "slack: shadow approval submitted");
|
|
757
|
+
} catch (err) {
|
|
758
|
+
logger.warn({ err, action }, "slack: failed to parse action value");
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
if (action.action_id.startsWith("shadow_override_") && payload.trigger_id) {
|
|
762
|
+
try {
|
|
763
|
+
const { pid } = JSON.parse(action.value);
|
|
764
|
+
await _openOverrideModal(payload.trigger_id, pid);
|
|
765
|
+
} catch (err) {
|
|
766
|
+
logger.warn({ err, action }, "slack: failed to open override modal");
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
if (action.action_id.startsWith("digest_approve_")) {
|
|
770
|
+
try {
|
|
771
|
+
const { id } = JSON.parse(action.value);
|
|
772
|
+
const result = await getStores().shadowLog.recordShadowVerdict(id, "would-approve", {});
|
|
773
|
+
if (result.found) recordVerdict(result.entry.pid, "correct");
|
|
774
|
+
logger.info({ id }, "slack: digest approval recorded");
|
|
775
|
+
} catch (err) {
|
|
776
|
+
logger.warn({ err, action }, "slack: failed to record digest approval");
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
if (action.action_id.startsWith("digest_override_")) {
|
|
780
|
+
try {
|
|
781
|
+
const { id } = JSON.parse(action.value);
|
|
782
|
+
if (payload.trigger_id) {
|
|
783
|
+
await _openOverrideModal(payload.trigger_id, `shadow:${id}`);
|
|
784
|
+
} else {
|
|
785
|
+
const result = await getStores().shadowLog.recordShadowVerdict(id, "would-override", { overrideReason: "on-call-discretion" });
|
|
786
|
+
if (result.found) recordVerdict(result.entry.pid, "wrong");
|
|
787
|
+
logger.info({ id }, "slack: digest override recorded (no trigger_id \u2014 no modal)");
|
|
788
|
+
}
|
|
789
|
+
} catch (err) {
|
|
790
|
+
logger.warn({ err, action }, "slack: failed to record digest override");
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
if (action.action_id.startsWith("feedback_")) {
|
|
794
|
+
try {
|
|
795
|
+
const { pid, verdict } = JSON.parse(action.value);
|
|
796
|
+
recordVerdict(pid, verdict);
|
|
797
|
+
logger.info({ pid, verdict }, "slack: feedback submitted via button");
|
|
798
|
+
} catch (err) {
|
|
799
|
+
logger.warn({ err, action }, "slack: failed to parse action value");
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (action.action_id.startsWith("inc_attr_correct_") || action.action_id.startsWith("inc_attr_wrong_")) {
|
|
803
|
+
const correct = action.action_id.startsWith("inc_attr_correct_");
|
|
804
|
+
const id = parseInt(action.value, 10);
|
|
805
|
+
if (!isNaN(id)) {
|
|
806
|
+
memoryStore.recordAttributionFeedback(id, correct ? 1 : 0);
|
|
807
|
+
logger.info({ id, correct }, "slack: attribution feedback via button");
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
if (action.action_id.startsWith("execute_fix_")) {
|
|
811
|
+
try {
|
|
812
|
+
const { pid } = JSON.parse(action.value);
|
|
813
|
+
const userId = payload.user?.id ?? "slack-user";
|
|
814
|
+
const record = approveExecution(pid);
|
|
815
|
+
if (record) {
|
|
816
|
+
logger.info({ pid, command: record.command, userId }, "slack: fix execution approved");
|
|
817
|
+
void postThreadReply(pid, `\u2699\uFE0F _Fix approved by <@${userId}> \u2014 executing\u2026_
|
|
818
|
+
\`${record.command}\``);
|
|
819
|
+
void executeRemediation(record.command, { cwd: record.cwd, actor: userId }).then((result) => {
|
|
820
|
+
if (result.blocked) {
|
|
821
|
+
void postThreadReply(pid, `\u{1F6AB} *Fix blocked by safety filter:* ${result.blockReason}`);
|
|
822
|
+
updateShadowReasonByPid(pid, "blocked-by-safety-filter");
|
|
823
|
+
} else if (!result.ok) {
|
|
824
|
+
void postThreadReply(pid, `\u274C *Fix command failed* (exit ${result.exitCode})
|
|
825
|
+
${result.stderr.slice(0, 500)}`);
|
|
826
|
+
updateShadowReasonByPid(pid, "executed-failure");
|
|
827
|
+
} else {
|
|
828
|
+
void postThreadReply(pid, `\u2705 *Fix executed* (${result.durationMs}ms)`);
|
|
829
|
+
updateShadowReasonByPid(pid, "executed");
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
} catch (err) {
|
|
834
|
+
logger.warn({ err, action }, "slack: failed to handle execute_fix action");
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
if (action.action_id.startsWith("edit_fix_trigger_") && payload.trigger_id) {
|
|
838
|
+
try {
|
|
839
|
+
const { pid, command } = JSON.parse(action.value);
|
|
840
|
+
await _openEditCommandModal(payload.trigger_id, pid, command);
|
|
841
|
+
} catch (err) {
|
|
842
|
+
logger.warn({ err, action }, "slack: failed to open edit command modal");
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (action.action_id.startsWith("deny_fix_")) {
|
|
846
|
+
try {
|
|
847
|
+
const { pid } = JSON.parse(action.value);
|
|
848
|
+
const userId = payload.user?.id ?? "slack-user";
|
|
849
|
+
if (denyExecution(pid)) {
|
|
850
|
+
recordVerdict(pid, "wrong", "denied via Slack");
|
|
851
|
+
void postThreadReply(pid, `\u{1F6AB} _Fix execution denied by <@${userId}>._`);
|
|
852
|
+
logger.info({ pid, userId }, "slack: fix execution denied");
|
|
853
|
+
updateShadowReasonByPid(pid, "denied");
|
|
854
|
+
}
|
|
855
|
+
} catch (err) {
|
|
856
|
+
logger.warn({ err, action }, "slack: failed to handle deny_fix action");
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
res.status(200).send("");
|
|
861
|
+
} catch (err) {
|
|
862
|
+
logger.warn({ err }, "slack: actions handler error");
|
|
863
|
+
res.status(200).send("");
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
async function handleFeedbackLink(req, res) {
|
|
867
|
+
const { pid, verdict } = req.query;
|
|
868
|
+
if (!pid || !verdict) {
|
|
869
|
+
res.status(400).send("Missing pid or verdict");
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
try {
|
|
873
|
+
recordVerdict(pid, verdict);
|
|
874
|
+
logger.info({ pid, verdict }, "feedback submitted via link");
|
|
875
|
+
res.send(`<!DOCTYPE html><html><head><title>Mergen Feedback</title></head><body style="font-family:system-ui;text-align:center;padding:60px">
|
|
876
|
+
<h2>${verdict === "correct" ? "\u2705" : "\u274C"} Feedback recorded</h2>
|
|
877
|
+
<p style="color:#666">Thank you. Mergen's accuracy will improve over time.</p>
|
|
878
|
+
<p style="margin-top:20px"><a href="/dashboard">Back to dashboard</a></p>
|
|
879
|
+
</body></html>`);
|
|
880
|
+
} catch (err) {
|
|
881
|
+
logger.warn({ err, pid, verdict }, "feedback link handler error");
|
|
882
|
+
res.status(500).send("Error recording feedback");
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
export {
|
|
886
|
+
fetchIncidentChannelContext,
|
|
887
|
+
fetchSlackThread,
|
|
888
|
+
getThread,
|
|
889
|
+
handleFeedbackLink,
|
|
890
|
+
handleSlackActions,
|
|
891
|
+
isInteractive,
|
|
892
|
+
postApprovalRequest,
|
|
893
|
+
postIncidentAlert,
|
|
894
|
+
postSimpleWebhookNotification,
|
|
895
|
+
postSlackAlert,
|
|
896
|
+
postThreadBlocks,
|
|
897
|
+
postThreadReply,
|
|
898
|
+
shouldAlert,
|
|
899
|
+
verifySlackSignature
|
|
900
|
+
};
|