Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
1568e48
feat(alerts): acknowledge alert incidents (core, API, MCP)
Makisuo Nov 6, 2026
5a66d2b
fix(alerts): clear an ack on escalation in the claimed write, decided…
Makisuo Nov 6, 2026
090cbf6
Merge remote-tracking branch 'origin/main' into feat/alert-ack-core
Makisuo Nov 6, 2026
836f27a
chore(ios): regenerate the OpenAPI spec for the incident acknowledgem…
Makisuo Nov 6, 2026
7ec39de
fix(alerts): keep the acknowledged filter on the incident list's next…
Makisuo Nov 6, 2026
9eaf37e
chore(domain): regenerate anticipated errors for AlertIncidentResolve…
Makisuo Nov 6, 2026
6577df5
fix(alerts): leave the internal incident id out of the resolved-incid…
Makisuo Nov 6, 2026
5abdc40
fix(alerts): attribute MCP acks like the audit log, stamp ack_cleared…
Makisuo Nov 6, 2026
d4b37ee
chore(alerts): yield before the call
Makisuo Nov 6, 2026
6a09658
Merge remote-tracking branch 'origin/main' into feat/alert-ack-core
Makisuo Nov 6, 2026
f93853b
Merge remote-tracking branch 'origin/main' into feat/alert-ack-core
Makisuo Nov 6, 2026
a929a95
feat(alerts): escalation chains that page step by step until someone …
Makisuo Nov 6, 2026
0a0e10a
test(mcp): acknowledge by inc_ id and assert the rendered text
Makisuo Nov 6, 2026
eac5819
test(alerts): escalation chains walk, repeat, stop on ack and resolve…
Makisuo Nov 6, 2026
17b8598
feat(alerts): escalation chains in the v2 API and MCP, and rule tiers…
Makisuo Nov 6, 2026
dcc44b1
Merge remote-tracking branch 'origin/feat/alert-ack-core' into feat/a…
Makisuo Nov 6, 2026
1598b16
feat(alerts): pure on-call schedule resolver
Makisuo Nov 6, 2026
3924399
fix(alerts): use Arr.findFirst for the delivery log's connector lookup
Makisuo Nov 6, 2026
16ae390
fix(alerts): escalation runs survive flapping, stop on de-escalation,…
Makisuo Nov 6, 2026
327bcce
feat(alerts): on-call schedules with layers, rotations and overrides
Makisuo Nov 6, 2026
5a8f016
feat(alerts): on-call schedules in the v2 API and MCP
Makisuo Nov 6, 2026
a697395
Merge branch 'feat/alert-escalation-chains' into feat/alert-oncall-sc…
Makisuo Nov 6, 2026
2e030b1
fix(web): a tier routed only by its escalation chain saves from the r…
Makisuo Nov 6, 2026
21f887c
test(web): a stored tier's escalation chain survives ruleToFormState …
Makisuo Nov 6, 2026
bdf9704
feat(alerts): page the on-call by email and chat direct message
Makisuo Nov 6, 2026
bd44ecb
Merge branch 'feat/alert-escalation-chains' into feat/alert-oncall-sc…
Makisuo Nov 6, 2026
46dd4da
fix(lint): effect/Array.findFirst for the delivery log's connector lo…
Makisuo Nov 6, 2026
db88c86
Merge remote-tracking branch 'origin/feat/alert-ack-core' into feat/a…
Makisuo Nov 6, 2026
f84d141
Merge remote-tracking branch 'origin/main' into feat/alert-escalation…
Makisuo Nov 6, 2026
b8defc3
chore(mcp): regenerate tool descriptors after merging main
Makisuo Nov 6, 2026
9ffa299
fix(api): a pre-1970 on-call timestamp is a 400, not a thrown constru…
Makisuo Nov 6, 2026
eef6186
fix(chat): connectors declare direct messages, and a rate limit retri…
Makisuo Nov 6, 2026
0b0094d
fix(alerts): member pages try every linked workspace, skip departed m…
Makisuo Nov 6, 2026
1af08d9
fix(alerts): schedules without a member directory, names without emai…
Makisuo Nov 6, 2026
d301b1b
Merge branch 'feat/alert-escalation-chains' into feat/alert-oncall-sc…
Makisuo Nov 6, 2026
0a6a76b
fix(alerts): an escalation step re-reads its incident before sending;…
Makisuo Nov 6, 2026
2893824
refactor(alerts): pages read in their own service, so acknowledging n…
Makisuo Nov 6, 2026
4221fa8
Merge branch 'feat/alert-escalation-chains' into feat/alert-oncall-sc…
Makisuo Nov 6, 2026
ab96cb0
fix(mcp): delete_escalation_chain's description says a chain-only tie…
Makisuo Nov 6, 2026
81bd621
fix(alerts): escalation spans record the org as orgId; the detach hin…
Makisuo Nov 6, 2026
9bff7e5
Merge branch 'feat/alert-escalation-chains' into feat/alert-oncall-sc…
Makisuo Nov 6, 2026
3c3f45f
fix(alerts): stale escalation steps are dropped from chat digests too…
Makisuo Nov 6, 2026
3c8d438
Merge branch 'feat/alert-escalation-chains' into feat/alert-oncall-sc…
Makisuo Nov 6, 2026
fb84b4c
fix(alerts): a destination a running escalation still pages cannot be…
Makisuo Nov 6, 2026
493a25e
Merge branch 'feat/alert-escalation-chains' into feat/alert-oncall-sc…
Makisuo Nov 6, 2026
7085053
fix(alerts): the pages read cap drops the oldest pages, never the newest
Makisuo Nov 6, 2026
45143a8
fix(web): a failed or skipped page reads as such in the incident time…
Makisuo Nov 6, 2026
29a7171
fix(alerts): on-call bounds are the real handoffs, not the lookaround…
Makisuo Nov 6, 2026
b5b7237
fix(api): on-call 400s name the wire field and what is wrong with it
Makisuo Nov 6, 2026
e1ebead
fix(alerts): incident timelines say why a page failed and read a queu…
Makisuo Nov 6, 2026
aa10819
fix(alerts): a page stopped by an ack after failed attempts says it s…
Makisuo Nov 6, 2026
2ce204b
test(mcp): import the shift document type the on-call tool stub uses
Makisuo Nov 6, 2026
02876c9
fix(alerts): an email page fails at once where the deployment cannot …
Makisuo Nov 6, 2026
de6959d
fix(api): v2 escalation chain step errors say what is wrong in wire t…
Makisuo Nov 6, 2026
254950e
fix(alerts): destination in-use conflict names escalation chains too
Makisuo Nov 6, 2026
74158df
fix(mcp): escalation chain tools report bad fields on their parameter…
Makisuo Nov 6, 2026
e721c66
Merge branch 'feat/alert-escalation-chains' into feat/alert-oncall-sc…
Makisuo Nov 6, 2026
2c1ffc6
fix(mcp): update_escalation_chain clears notes with description "none"
Makisuo Nov 6, 2026
7b80706
Merge branch 'feat/alert-escalation-chains' into feat/alert-oncall-sc…
Makisuo Nov 6, 2026
66d2375
Merge remote-tracking branch 'origin/main' into feat/alert-escalation…
Makisuo Nov 6, 2026
1352903
feat(alerts): a silence holds an escalation's due step until it ends
Makisuo Nov 6, 2026
2880947
Merge branch 'feat/alert-escalation-chains' into feat/alert-oncall-sc…
Makisuo Nov 6, 2026
2cd8b77
fix(alerts): a held step skips a destination the silence's renotify c…
Makisuo Nov 6, 2026
7af3d92
Merge branch 'feat/alert-escalation-chains' into feat/alert-oncall-sc…
Makisuo Nov 6, 2026
a99c2ad
Merge remote-tracking branch 'origin/main' into feat/alert-escalation…
Makisuo Nov 6, 2026
9bf3fb1
Merge branch 'feat/alert-escalation-chains' into feat/alert-oncall-sc…
Makisuo Nov 6, 2026
c51738f
Merge remote-tracking branch 'origin/main' into feat/alert-escalation…
Makisuo Nov 6, 2026
2a3582f
Merge branch 'feat/alert-escalation-chains' into feat/alert-oncall-sc…
Makisuo Nov 6, 2026
62aeecf
Merge remote-tracking branch 'origin/main' into feat/alert-escalation…
Makisuo Nov 6, 2026
a5a2302
Merge branch 'feat/alert-escalation-chains' into feat/alert-oncall-sc…
Makisuo Nov 6, 2026
3ef14dc
Merge remote-tracking branch 'origin/main' into feat/alert-oncall-sch…
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
4 changes: 4 additions & 0 deletions apps/ai/src/mcp/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ 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 { AlertIncidentPagesService } from "@maple/backend/services/alerts/AlertIncidentPagesService"
import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService"
import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService"
import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService"
import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService"

import { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService"
Expand Down Expand Up @@ -274,8 +276,10 @@ const McpRuntimeServicesLive = Layer.mergeAll(
AgentFeedbackService.layer,
AlertReadModelsService.layer,
AlertIncidentAckService.layer,
AlertIncidentPagesService.layer,
AlertRulesService.layer,
EscalationChainsService.layer,
OnCallSchedulesService.layer,
AlertSilencesService.layer,
AlertsService.layer,
AuditLogService.layer,
Expand Down
219 changes: 167 additions & 52 deletions apps/ai/src/mcp/lib/escalation-chains.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
/** Pieces the escalation chain MCP tools share: rows, step text, step input and error mapping. */
import { Array as Arr, Effect, Option, Schema, SchemaIssue } from "effect"
import type {
AlertDestinationDocument,
AlertForbiddenError,
OrgId,
AlertPersistenceError,
AlertValidationError,
EscalationChainDocument,
EscalationChainInUseError,
EscalationChainNotFoundError,
import {
UserId,
type AlertDestinationDocument,
type AlertForbiddenError,
type AlertMemberDirectoryUnavailableError,
type OrgId,
type AlertPersistenceError,
type AlertValidationError,
type EscalationChainDocument,
type EscalationChainInUseError,
type EscalationChainNotFoundError,
type EscalationTarget,
} from "@maple/domain/http"
import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2"
import type { EscalationChainRow } from "@maple/domain/mcp-outputs"
import { AlertsService } from "@maple/backend/services/alerts/AlertsService"
import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService"
import { McpInvalidInputError } from "../tools/types"
import { toMcpHttpError } from "./map-http-error"
import * as P from "./params"
Expand All @@ -21,63 +25,154 @@ import { doc } from "./tool-doc"
type ChainRow = typeof EscalationChainRow.Type
type DestinationRef = Pick<AlertDestinationDocument, "id" | "name" | "type">

/** The steps a tool takes: each the destinations it notifies and the minutes it waits. */
/** Names the rendered steps show; each part is empty when it could not be read. */
export interface TargetDirectory {
readonly destinations: ReadonlyArray<DestinationRef>
readonly members: ReadonlyMap<string, string>
readonly schedules: ReadonlyArray<{ readonly id: string; readonly name: string }>
}

/** The steps a tool takes: each the destinations, people and schedules it pages, and its wait. */
export const StepsInput = Schema.Array(
Schema.Struct({
destination_ids: Schema.Array(Schema.String).annotate({
description: "Destinations this step notifies, from list_alert_destinations (UUID or dest_…)",
}),
destination_ids: Schema.optionalKey(
Schema.Array(Schema.String).annotate({
description: "Destinations this step notifies, from list_alert_destinations (UUID or dest_…)",
}),
),
user_ids: Schema.optionalKey(
Schema.Array(Schema.String).annotate({
description:
"Members this step pages by email and chat direct message (Maple user ids, user_…)",
}),
),
schedule_ids: Schema.optionalKey(
Schema.Array(Schema.String).annotate({
description:
"On-call schedules whose on-call person this step pages, from list_oncall_schedules (UUID or onc_…)",
}),
),
wait_minutes: Schema.Number.annotate({
description: "Minutes to wait for an acknowledgement before the next step, 1 to 1440",
}),
}),
)

export const stepsParameterDescription =
'Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{"destination_ids":["dest_…"],"wait_minutes":10},{"destination_ids":["dest_…"],"wait_minutes":15}]. At most 10 steps of 10 destinations'
'Steps in order, each paging its targets then waiting for an ack before the next, e.g. [{"schedule_ids":["onc_…"],"wait_minutes":10},{"user_ids":["user_…"],"destination_ids":["dest_…"],"wait_minutes":15}]. A step names at least one of destination_ids (alert destinations), user_ids (members, paged by email and chat DM) or schedule_ids (whoever is on call then). At most 10 steps of 10 targets'

const decodeUserId = Schema.decodeUnknownEffect(UserId)

/** The steps as the service schema takes them, destination ids decoded to internal ids. */
/** The steps as the service schema takes them, ids decoded to internal ids. */
export const toStepsRequest = (steps: typeof StepsInput.Type) =>
Effect.forEach(steps, (step) =>
Effect.map(
Effect.forEach(step.destination_ids, P.decodeResourceId("alertDestination", "steps")),
(ids) => ({
targets: Arr.map(ids, (destinationId) => ({ type: "destination" as const, destinationId })),
waitMinutes: step.wait_minutes,
}),
),
Effect.gen(function* () {
const destinationIds = yield* Effect.forEach(
step.destination_ids ?? [],
P.decodeResourceId("alertDestination", "steps"),
)
const userIds = yield* Effect.forEach(step.user_ids ?? [], (raw) =>
decodeUserId(raw).pipe(
Effect.mapError(
() =>
new McpInvalidInputError({
message: `Not a user id: ${raw}`,
parameter: "steps",
}),
),
),
)
const scheduleIds = yield* Effect.forEach(
step.schedule_ids ?? [],
P.decodeResourceId("oncallSchedule", "steps"),
)
const targets: ReadonlyArray<EscalationTarget> = [
...Arr.map(destinationIds, (destinationId) => ({
type: "destination" as const,
destinationId,
})),
...Arr.map(userIds, (userId) => ({ type: "user" as const, userId })),
...Arr.map(scheduleIds, (scheduleId) => ({ type: "schedule" as const, scheduleId })),
]
return { targets, waitMinutes: step.wait_minutes }
}),
)

/** Destination names for the rendered steps; a failed read only drops the names. */
export const destinationDirectory = (orgId: OrgId) =>
/** Names for the rendered steps; a failed read only drops the names. */
export const targetDirectory = (orgId: OrgId) =>
Effect.gen(function* () {
const alerts = yield* AlertsService
return yield* alerts.listDestinations(orgId).pipe(
Effect.map((response): ReadonlyArray<DestinationRef> => response.destinations),
Effect.orElseSucceed((): ReadonlyArray<DestinationRef> => []),
const schedules = yield* OnCallSchedulesService
const [destinations, members, scheduleList] = yield* Effect.all(
[
alerts.listDestinations(orgId).pipe(
Effect.map((response): ReadonlyArray<DestinationRef> => response.destinations),
Effect.orElseSucceed((): ReadonlyArray<DestinationRef> => []),
),
schedules.memberNames(orgId),
schedules.list(orgId).pipe(
Effect.map((response) =>
Arr.map(response.schedules, (s) => ({ id: s.id, name: s.name })),
),
Effect.orElseSucceed(() => []),
),
],
{ concurrency: "unbounded" },
)
return { destinations, members, schedules: scheduleList } satisfies TargetDirectory
})

const toTargetRow = (
target: EscalationTarget,
directory: TargetDirectory,
): ChainRow["steps"][number]["targets"][number] => {
switch (target.type) {
case "destination": {
const known = Option.getOrUndefined(
Arr.findFirst(directory.destinations, (d) => d.id === target.destinationId),
)
return {
kind: "destination",
id: target.destinationId,
publicId: encodePublicId(PublicIdPrefixes.alertDestination, target.destinationId),
name: known?.name ?? null,
type: known?.type ?? null,
}
}
case "user":
return {
kind: "user",
id: target.userId,
publicId: target.userId,
name: directory.members.get(target.userId) ?? null,
type: null,
}
case "schedule":
return {
kind: "schedule",
id: target.scheduleId,
publicId: encodePublicId(PublicIdPrefixes.oncallSchedule, target.scheduleId),
name: Option.getOrNull(
Option.map(
Arr.findFirst(directory.schedules, (s) => s.id === target.scheduleId),
(s) => s.name,
),
),
type: null,
}
}
}

export const toEscalationChainRow = (
chain: EscalationChainDocument,
destinations: ReadonlyArray<DestinationRef>,
directory: TargetDirectory,
): ChainRow => ({
id: chain.id,
publicId: encodePublicId(PublicIdPrefixes.escalationChain, chain.id),
name: chain.name,
description: chain.description,
steps: Arr.map(chain.steps, (step) => ({
targets: Arr.map(step.targets, (target) => {
const known = Option.getOrUndefined(
Arr.findFirst(destinations, (d) => d.id === target.destinationId),
)
return {
destinationId: target.destinationId,
destinationPublicId: encodePublicId(PublicIdPrefixes.alertDestination, target.destinationId),
name: known?.name ?? null,
type: known?.type ?? null,
}
}),
targets: Arr.map(step.targets, (target) => toTargetRow(target, directory)),
waitMinutes: step.waitMinutes,
})),
repeatCount: chain.repeatCount,
Expand All @@ -93,14 +188,22 @@ const formatWait = (minutes: number): string =>
? `${minutes / 60}h`
: `${Math.floor(minutes / 60)}h ${minutes % 60}m`

const targetLabel = (target: ChainRow["steps"][number]["targets"][number]): string =>
target.name === null
? `destination ${target.destinationPublicId}`
: target.type === null
? `${target.name} (${target.destinationPublicId})`
: `${target.name} (${target.type}, ${target.destinationPublicId})`
const targetLabel = (target: ChainRow["steps"][number]["targets"][number]): string => {
switch (target.kind) {
case "destination":
return target.name === null
? `destination ${target.publicId}`
: target.type === null
? `${target.name} (${target.publicId})`
: `${target.name} (${target.type}, ${target.publicId})`
case "user":
return target.name === null ? `user ${target.publicId}` : `${target.name} (${target.publicId})`
case "schedule":
return `whoever is on call on ${target.name === null ? `schedule ${target.publicId}` : `${target.name} (${target.publicId})`}`
}
}

/** One line per step, e.g. `1. notify On-call (pagerduty, dest_…), then wait 10m for an ack`. */
/** One line per step, e.g. `1. notify On-call (pagerduty, dest_…), Ana (user_…), then wait 10m for an ack`. */
const formatSteps = (chain: ChainRow): string =>
Arr.join(
Arr.map(
Expand Down Expand Up @@ -142,8 +245,6 @@ const toolKey = (segment: PropertyKey): string => {
return "repeat_count"
case "waitMinutes":
return "wait_minutes"
case "targets":
return "destination_ids"
default:
return key
}
Expand All @@ -152,25 +253,37 @@ const toolKey = (segment: PropertyKey): string => {
/** A request that failed to decode, reported against the parameter its first issue is under. */
export const invalidChainRequest = (error: Schema.SchemaError): McpInvalidInputError => {
const first = formatIssues(error.issue).issues[0]
const path = Arr.map(first?.path ?? [], (segment) =>
const issuePath = Arr.map(first?.path ?? [], (segment) =>
typeof segment === "object" && segment !== null ? segment.key : segment,
)
// A step's targets come from three id lists, so a targets issue is reported on the step.
const targetsAt = Arr.findFirstIndex(issuePath, (segment) => segment === "targets")
const path = Option.match(targetsAt, { onNone: () => issuePath, onSome: (at) => issuePath.slice(0, at) })
const [root] = path
const location = Arr.join(
Arr.map(path, (segment, index) =>
typeof segment === "number" ? `[${segment}]` : `${index === 0 ? "" : "."}${toolKey(segment)}`,
),
"",
)
const message = first?.message ?? String(error)
const issueMessage = first?.message ?? String(error)
const message = Option.match(targetsAt, {
onNone: () => issueMessage,
onSome: (at) =>
at === issuePath.length - 1
? `destination_ids, user_ids and schedule_ids together: ${issueMessage}`
: "needs at least one id in destination_ids, user_ids or schedule_ids",
})
return new McpInvalidInputError({
message: location === "" ? `Invalid escalation chain: ${message}` : `${location}: ${message}`,
parameter: root === undefined ? "steps" : toolKey(root),
})
}

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

const invalidChain = (error: AlertValidationError) =>
Effect.fail(
Expand All @@ -184,6 +297,7 @@ export const createChainErrors = {
"@maple/http/errors/AlertValidationError": invalidChain,
"@maple/http/errors/AlertForbiddenError": failWith("create_escalation_chain"),
"@maple/http/errors/AlertPersistenceError": failWith("create_escalation_chain"),
"@maple/http/errors/AlertMemberDirectoryUnavailableError": failWith("create_escalation_chain"),
}

export const updateChainErrors = {
Expand All @@ -192,6 +306,7 @@ export const updateChainErrors = {
Effect.fail(chainNotFound(error.chainId)),
"@maple/http/errors/AlertForbiddenError": failWith("update_escalation_chain"),
"@maple/http/errors/AlertPersistenceError": failWith("update_escalation_chain"),
"@maple/http/errors/AlertMemberDirectoryUnavailableError": failWith("update_escalation_chain"),
}

export const deleteChainErrors = {
Expand Down
Loading
Loading