Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
29c2aa9
feat(alerts): alert silences that hold back notifications
Makisuo Nov 6, 2026
d7e995b
feat(alerts): MCP tools for alert silences
Makisuo Nov 6, 2026
227eba3
fix(alerts): record held-back pages under the claim; count in-flight …
Makisuo Nov 6, 2026
fb5754e
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
5a58f05
Merge remote-tracking branch 'origin/main' into feat/alert-silences
Makisuo Nov 6, 2026
4d3a1f1
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
6a15781
chore(domain): regenerate anticipated error identifiers for AlertSile…
Makisuo Nov 6, 2026
c3f3817
fix(ai): list_alert_silences keeps scheduled silences in view behind …
Makisuo Nov 6, 2026
8bfef02
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
2a847e4
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
fddf076
chore(ios): regenerate the OpenAPI spec for alert silences
Makisuo Nov 6, 2026
76da4b8
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
8a9aee6
fix(alerts): purge silences with their org; exact silence durations
Makisuo Nov 6, 2026
3618c51
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
80004c5
fix(alerts): match silences on a builder query's grouping; commit cat…
Makisuo Nov 6, 2026
7b6b193
test(ai): create_alert_silence with ends_at alone and a non-positive …
Makisuo Nov 6, 2026
f757e49
Merge remote-tracking branch 'origin/main' into feat/alert-silences
Makisuo Nov 6, 2026
2087c76
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
115cdff
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
d8401e1
test(web): add the silence fields to main's v2 incident fixtures
Makisuo Nov 6, 2026
3c6ac02
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
083cbf2
fix(alerts): no catch-up page for an incident acknowledged during its…
Makisuo Nov 6, 2026
6ca5cef
fix(ai): create_alert_silence keeps the validation details (which rul…
Makisuo Nov 6, 2026
b772d82
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
4a135b6
fix(alerts): claim the catch-up page in SQL against a late ack; an es…
Makisuo Nov 6, 2026
44cab13
fix(ai): log a failed silence reason lookup in get_incident_timeline
Makisuo Nov 6, 2026
000d3a3
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
ea50321
fix(alerts): a catch-up's notification anchor moves only when the cat…
Makisuo Nov 6, 2026
ddf435f
fix(ai): the timeline reads a settled silence hold as past, not as th…
Makisuo Nov 6, 2026
e9f0de8
fix(alerts): a phone paged before a silence still gets the all-clear …
Makisuo Nov 6, 2026
0dc8523
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
4c5022f
fix(alerts): withhold the phone all-clear only for an incident opened…
Makisuo Nov 6, 2026
f3a350d
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
19975b2
Merge remote-tracking branch 'origin/main' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
7695cf4
fix(mcp): page one silence status with an offset, cap silence duratio…
Makisuo Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/ai/src/mcp/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { AlertsService } from "@maple/backend/services/alerts/AlertsService"
import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService"
import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService"
import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService"
import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService"

import { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService"
import { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService"
Expand Down Expand Up @@ -273,6 +274,7 @@ const McpRuntimeServicesLive = Layer.mergeAll(
AlertReadModelsService.layer,
AlertIncidentAckService.layer,
AlertRulesService.layer,
AlertSilencesService.layer,
AlertsService.layer,
AuditLogService.layer,
DashboardPersistenceService.layer,
Expand Down
97 changes: 97 additions & 0 deletions apps/ai/src/mcp/lib/alert-silences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Array as Arr, Effect } from "effect"
import type {
AlertForbiddenError,
AlertPersistenceError,
AlertSilenceDocument,
AlertSilenceMatchers,
AlertSilenceNotFoundError,
AlertValidationError,
} from "@maple/domain/http"
import type { AlertSilenceRow } from "@maple/domain/mcp-outputs"
import { McpInvalidInputError } from "../tools/types"
import { toMcpHttpError } from "./map-http-error"
import { doc } from "./tool-doc"

export const ALERT_SILENCE_STATUSES = ["scheduled", "active", "ended"] as const

type SilenceRow = typeof AlertSilenceRow.Type

export const toAlertSilenceRow = (silence: AlertSilenceDocument): SilenceRow => ({
id: silence.id,
reason: silence.reason,
matchers: {
...(silence.matchers.ruleIds === undefined ? undefined : { ruleIds: [...silence.matchers.ruleIds] }),
...(silence.matchers.tags === undefined ? undefined : { tags: [...silence.matchers.tags] }),
...(silence.matchers.services === undefined
? undefined
: { services: [...silence.matchers.services] }),
...(silence.matchers.environments === undefined
? undefined
: { environments: [...silence.matchers.environments] }),
},
status: silence.status,
startsAt: silence.startsAt,
endsAt: silence.endsAt,
createdBy: silence.createdBy,
})

/** The matchers as one line, e.g. `services: checkout AND environments: production`. */
export const formatSilenceMatchers = (matchers: SilenceRow["matchers"] | AlertSilenceMatchers): string => {
const entries: ReadonlyArray<readonly [string, ReadonlyArray<string> | undefined]> = [
["rules", matchers.ruleIds],
["tags", matchers.tags],
["services", matchers.services],
["environments", matchers.environments],
]
const parts = Arr.flatMap(entries, ([label, values]) =>
values === undefined || values.length === 0 ? [] : [`${label}: ${Arr.join(values, ", ")}`],
)
return parts.length === 0 ? "-" : Arr.join(parts, " AND ")
}

export const silenceFields = (silence: SilenceRow) =>
doc.fields([
["ID", silence.id],
["Reason", silence.reason],
["Status", silence.status],
["Matches", formatSilenceMatchers(silence.matchers)],
["Starts", silence.startsAt],
["Ends", silence.endsAt],
["Created by", silence.createdBy],
])

export const silenceNotFound = (silenceId: string) =>
new McpInvalidInputError({
message: `Alert silence not found: ${silenceId}. Use list_alert_silences to find silence IDs.`,
parameter: "silence_id",
})

const failWith = (pipeName: string) => (error: AlertForbiddenError | AlertPersistenceError) =>
Effect.fail(toMcpHttpError(pipeName)(error))

/** create_alert_silence failures: an invalid window or matcher is the caller's to fix. */
export const createSilenceErrors = {
// The details name what to fix (which rule ids do not exist), so they ride along.
"@maple/http/errors/AlertValidationError": (error: AlertValidationError) =>
Effect.fail(
new McpInvalidInputError({
message: Arr.join(
[
`Invalid alert silence: ${error.message}`,
...Arr.filter(error.details, (detail) => detail !== error.message),
],
". ",
),
}),
),
"@maple/http/errors/AlertForbiddenError": failWith("create_alert_silence"),
"@maple/http/errors/AlertPersistenceError": failWith("create_alert_silence"),
}

/** end_alert_silence failures: an unknown id is invalid input on silence_id. */
export const endSilenceErrors = {
"@maple/http/errors/AlertSilenceNotFoundError": (error: AlertSilenceNotFoundError) =>
Effect.fail(silenceNotFound(error.silenceId)),
"@maple/http/errors/AlertForbiddenError": failWith("end_alert_silence"),
"@maple/http/errors/AlertPersistenceError": failWith("end_alert_silence"),
}
2 changes: 2 additions & 0 deletions apps/ai/src/mcp/lib/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
AlertDestinationId,
AlertIncidentId,
AlertRuleId,
AlertSilenceId,
DashboardId,
ErrorIssueId,
} from "@maple/domain/primitives"
Expand Down Expand Up @@ -157,6 +158,7 @@ const RESOURCE_IDS = {
schema: AlertDestinationId,
noun: "alert destination",
},
alertSilence: { prefix: PublicIdPrefixes.alertSilence, schema: AlertSilenceId, noun: "alert silence" },
dashboard: { prefix: PublicIdPrefixes.dashboard, schema: DashboardId, noun: "dashboard" },
errorIssue: { prefix: PublicIdPrefixes.errorIssue, schema: ErrorIssueId, noun: "error issue" },
} as const
Expand Down
1 change: 1 addition & 0 deletions apps/ai/src/mcp/resources/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ first occurrences ──> (candidate) ──> issue in \`triage\`
- Infrastructure (hosts, Kubernetes pods/nodes/workloads, Docker containers): list_infra (no \`kind\` = overview of what reports, hottest first) -> inspect_infra (CPU/memory over the window with the peak time, a node's or workload's pods). diagnose_service shows the workload and pods a service runs on. Connected integrations add kinds: cloudflare_zones / cloudflare_workers (requests, 5xx, latency; inspect a zone with section=traffic|breakdown|security|dns), railway_services and planetscale_databases; list_integrations says which are connected and healthy. Resource metrics are also chartable: create_dashboard group_by \`resource.host.name\` / \`resource.k8s.pod.name\` on source=metrics
- Deploy verification: service_deployments (versions per service, newest vs previous) -> compare_periods around the rollout. Route usage over up to 90 days: route_usage. Silence check before calling an outage: ingest_freshness. Volume per service: ingest_usage. Database query shapes by volume: db_query_volume
- Alert management: list_alert_rules / list_alert_destinations -> get_alert_rule -> preview_alert_rule (dry-run before saving; a window with no data is a blind rule, not a healthy one) -> create_alert_rule / update_alert_rule / delete_alert_rule -> list_alert_incidents
- Quieting alerts for a window (maintenance, a known outage): create_alert_silence (matchers + duration_minutes or ends_at) / list_alert_silences / end_alert_silence. Incidents still open; only notifications are held back.
- Uptime: list_uptime_monitors is every http/tcp/dns check and heartbeat with its status and last check; a failed check's trace id goes to inspect_trace. Admins manage them with create_uptime_monitor / update_uptime_monitor (enabled=false pauses) / delete_uptime_monitor. A down monitor opens an alert incident with signal type \`uptime\`; its rule is managed only through the monitor.
- SLOs: list_slos is every SLO with its SLI, budget left and worst burn rate, burning ones first -> inspect_slo (each 5m/30m/1h/6h burn window, which tier breaches, alerting) -> get_incident_timeline rule_id=<its alert rule>. Admins and engineers manage them with create_slo / update_slo (alerting_enabled=false silences it) / delete_slo. An SLO's burn-rate rule (signal type \`slo_burn_rate\`) is managed only through the SLO.
- Status pages (what customers see, separate from alert incidents): list_status_pages -> get_status_page (component statuses, open incidents, freshness) -> create_status_page_incident / schedule_status_page_maintenance (drafts unless publish=true) -> post_status_page_update -> resolve_status_page_incident. Every message is public text. Admins edit a whole page (settings, theme, components) with get_status_page_config -> apply_status_page_config, one atomic write.
Expand Down
5 changes: 4 additions & 1 deletion apps/ai/src/mcp/tool-descriptors.generated.ts

Large diffs are not rendered by default.

Loading
Loading