Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9c52485
feat(alerts): acknowledge incidents from Slack/Discord cards and in P…
Makisuo Nov 6, 2026
aa3af09
Merge branch 'feat/alert-ack-ui' into feat/alert-ack-chat
Makisuo Nov 6, 2026
9bde5ae
fix(alerts): leave a skipped PagerDuty acknowledgement out of the inc…
Makisuo Nov 6, 2026
a70f26b
Merge branch 'feat/alert-ack-ui' into feat/alert-ack-chat
Makisuo Nov 6, 2026
8dd1565
Merge branch 'feat/alert-ack-ui' into feat/alert-ack-chat
Makisuo Nov 6, 2026
491a798
Merge branch 'feat/alert-ack-ui' into feat/alert-ack-chat
Makisuo Nov 6, 2026
38780a7
fix(alerts): keep PagerDuty acknowledgements internal and only send t…
Makisuo Nov 6, 2026
d7ecc60
chore(alerts): drop an unused import
Makisuo Nov 6, 2026
ac8c3e5
Merge branch 'feat/alert-ack-ui' into feat/alert-ack-chat
Makisuo Nov 6, 2026
04fe605
Merge branch 'feat/alert-ack-ui' into feat/alert-ack-chat
Makisuo Nov 6, 2026
b7fa3d4
Merge branch 'feat/alert-ack-ui' into feat/alert-ack-chat
Makisuo Nov 6, 2026
25b4983
Merge branch 'feat/alert-ack-ui' into feat/alert-ack-chat
Makisuo Nov 6, 2026
7826d2c
Merge remote-tracking branch 'origin/feat/alert-ack-ui' into feat/ale…
Makisuo Nov 6, 2026
9bf0cc6
Merge remote-tracking branch 'origin/feat/alert-ack-ui' into feat/ale…
Makisuo Nov 6, 2026
9ae94d1
Merge remote-tracking branch 'origin/feat/alert-ack-ui' into feat/ale…
Makisuo Nov 6, 2026
80b6e48
Merge remote-tracking branch 'origin/feat/alert-ack-ui' into feat/ale…
Makisuo Nov 6, 2026
a1513dc
Merge remote-tracking branch 'origin/feat/alert-ack-ui' into feat/ale…
Makisuo Nov 6, 2026
8487a74
Merge remote-tracking branch 'origin/feat/alert-ack-ui' into feat/ale…
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
21 changes: 16 additions & 5 deletions apps/api/src/routes/v2/alert-deliveries.http.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { HttpApiBuilder } from "effect/http-api"
import type { AlertDeliveryEventDocument } from "@maple/domain/http"
import { CurrentTenant } from "@maple/domain/http"
import type { AlertDeliveryEventDocument, AlertNotificationEventType } from "@maple/domain/http"
import { CurrentTenant, isAlertNotificationEventType } from "@maple/domain/http"
import type { V2AlertDelivery } from "@maple/domain/http/v2"
import { MapleApiV2, paginateOffsetQuery, timestamp, timestampOrNull } from "@maple/domain/http/v2"
import { Effect, Array as Arr } from "effect"
import { Effect, Result, Array as Arr } from "effect"
import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService"

const toV2Delivery = (doc: AlertDeliveryEventDocument): V2AlertDelivery => ({
const toV2Delivery = (
doc: AlertDeliveryEventDocument & { readonly eventType: AlertNotificationEventType },
): V2AlertDelivery => ({
id: doc.id,
object: "alert_delivery",
incident_id: doc.incidentId,
Expand Down Expand Up @@ -39,10 +41,19 @@ export const HttpV2AlertDeliveriesLive = HttpApiBuilder.group(MapleApiV2, "alert
? { incidentId: query.incident_id }
: undefined),
...(query.rule_id !== undefined ? { ruleId: query.rule_id } : undefined),
notificationsOnly: true,
limit,
offset,
})
.pipe(Effect.map((response) => Arr.map(response.events, toV2Delivery))),
.pipe(
Effect.map((response) =>
Arr.filterMap(response.events, (doc) =>
isAlertNotificationEventType(doc.eventType)
? Result.succeed(toV2Delivery({ ...doc, eventType: doc.eventType }))
: Result.failVoid,
),
),
),
)
return { object: "list" as const, ...page }
}),
Expand Down
8 changes: 6 additions & 2 deletions apps/api/src/routes/v2/alert-incidents.http.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { HttpApiBuilder } from "effect/http-api"
import type { AlertIncidentDocument, AlertIncidentEventDocument } from "@maple/domain/http"
import { CurrentTenant } from "@maple/domain/http"
import { CurrentTenant, isAlertNotificationEventType } from "@maple/domain/http"
import type { V2AlertIncident, V2AlertIncidentEvent } from "@maple/domain/http/v2"
import { MapleApiV2, paginateOffsetQuery } from "@maple/domain/http/v2"
import { Duration, Effect, Option, Array as Arr } from "effect"
Expand Down Expand Up @@ -31,7 +31,11 @@ const toV2Incident = (doc: AlertIncidentDocument): V2AlertIncident => ({
last_observed_value: doc.lastObservedValue,
last_sample_count: doc.lastSampleCount,
dedupe_key: doc.dedupeKey,
last_delivered_event_type: doc.lastDeliveredEventType,
// An acknowledgement never becomes the last delivered event; narrow it for the public enum.
last_delivered_event_type:
doc.lastDeliveredEventType !== null && isAlertNotificationEventType(doc.lastDeliveredEventType)
? doc.lastDeliveredEventType
: null,
last_notified_at: doc.lastNotifiedAt,
hold_reason: doc.holdReason,
held_since: doc.heldSince,
Expand Down
15 changes: 14 additions & 1 deletion apps/api/src/routes/v2/alerts.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,19 @@ describe("v2 alerts over HTTP", () => {
)`,
)

// An internal PagerDuty acknowledgement sync is not a notification: the public list leaves it out.
await executeSql(
harness.testDb,
`insert into alert_delivery_events (
id, org_id, rule_id, destination_id, delivery_key, event_type,
attempt_number, status, scheduled_at, payload_json, created_at, updated_at
)
select '00000000-0000-4000-9000-0000000000ac', rules.org_id, rules.id, destinations.id,
'ack-sync', 'acknowledge', 1, 'success', timestamp '2026-08-02 12:00:00+00', '{}'::jsonb,
timestamp '2026-08-02 12:00:00+00', timestamp '2026-08-02 12:00:00+00'
from (select id, org_id from alert_rules where org_id = 'org_alerts_e2e' limit 1) as rules
cross join (select id from alert_destinations where org_id = 'org_alerts_e2e' limit 1) as destinations`,
)
const first = await harness.request("GET", "/v2/alerts/deliveries?limit=60", key.secret)
expect(first.status).toBe(200)
expect(first.body.data).toHaveLength(60)
Expand All @@ -455,7 +468,7 @@ describe("v2 alerts over HTTP", () => {
expect(
Arr.some(
[...first.body.data, ...second.body.data],
(item) => item.delivery_key === "foreign-delivery",
(item) => item.delivery_key === "foreign-delivery" || item.delivery_key === "ack-sync",
),
).toBe(false)

Expand Down
72 changes: 71 additions & 1 deletion apps/chat-bot/src/relay/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@
import type { ChatConnector, ConnectorCredentials, InboundEvent } from "@maple/chat-platform"
import { connectors } from "@maple/chat-platform/connectors"
import type { ChatConnectorId, OrgId } from "@maple/domain/primitives"
import type { IntegrationsPersistenceError } from "@maple/domain/http"
import {
AlertIncidentAckSource,
AlertIncidentId,
type IntegrationsPersistenceError,
} from "@maple/domain/http"
import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService"
import { AlertChatCardService } from "@maple/backend/services/alerts/AlertChatCardService"
import { runtimeTelemetry } from "@maple/infra/worker-telemetry"
import { ChatSessions, type ChatSessionsApi } from "@maple/backend/platform/bindings"
import { type ChatSessionNamespace, chatSessionsLayerIfBound } from "@maple/backend/platform/chat-sessions"
Expand All @@ -35,6 +41,7 @@ import {
relayWithWorkspace,
withWorkspace,
WorkspaceLookupFailed,
type RelayAlertAckOutcome,
type RelayPorts,
type ResolvedWorkspace,
} from "./turn.ts"
Expand Down Expand Up @@ -201,6 +208,68 @@ const lookupWorkspace = (
).pipe(lookupFailed(connectorId, "The chat workspace could not be read")),
)

const decodeAckSource = Schema.decodeUnknownOption(AlertIncidentAckSource)
const decodeIncidentId = Schema.decodeUnknownOption(AlertIncidentId)

/**
* An alert card's ack click, in one connection: the ack (which also queues the PagerDuty
* acknowledgement), then the card redrawn with its new state. Every failure is an outcome.
*/
const alertAck =
(host: Pick<RelayHost, "env">): RelayPorts["alertAck"] =>
(request) => {
const source = decodeAckSource(request.connector)
const incidentId = decodeIncidentId(request.incidentId)
if (Option.isNone(source)) return Effect.succeed<RelayAlertAckOutcome>({ kind: "unavailable" })
if (Option.isNone(incidentId)) return Effect.succeed<RelayAlertAckOutcome>({ kind: "not_found" })
return withDatabase(
host.env,
Effect.gen(function* () {
const acks = yield* AlertIncidentAckService
const cards = yield* AlertChatCardService
const actor = { userId: request.userId, name: request.displayName, source: source.value }
const change =
request.action === "acknowledge"
? acks.acknowledge(request.orgId, incidentId.value, actor)
: acks.unacknowledge(request.orgId, incidentId.value, actor)
const applied = yield* change.pipe(
Effect.as<"applied" | "resolved" | "not_found">("applied"),
Effect.catchTags({
"@maple/http/errors/AlertIncidentNotFoundError": () =>
Effect.succeed("not_found" as const),
"@maple/http/errors/AlertIncidentResolvedError": () =>
Effect.succeed("resolved" as const),
}),
)
if (applied === "not_found") return { kind: "not_found" } satisfies RelayAlertAckOutcome
// The ack stands even when the card cannot be redrawn; the clicker is told instead.
const card = yield* cards
.cardForMessage(request.orgId, incidentId.value, request.messageId)
.pipe(
Effect.tapError((error) =>
Effect.logWarning("An alert card could not be rebuilt").pipe(
Effect.annotateLogs({ "error.type": error._tag }),
),
),
Effect.orElseSucceed(() => Option.none()),
)
return { kind: applied, card } satisfies RelayAlertAckOutcome
}).pipe(
// Built per event: the services hold the event's own connection.
Effect.provide([AlertIncidentAckService.layer, AlertChatCardService.layer]),
),
).pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.interrupt
: Effect.logError("An alert acknowledgement from chat failed").pipe(
Effect.annotateLogs({ "error.type": summarizeCause(cause) }),
Effect.as<RelayAlertAckOutcome>({ kind: "unavailable" }),
),
),
)
}

/** What a relayed event reads from the graph rather than the object: settings and the chat-session port. */
interface RelayEnvironment {
readonly settings: RelaySettings
Expand Down Expand Up @@ -258,6 +327,7 @@ const ports = (
ownsConversation: host.ownsConversation,
rememberConversation: host.rememberConversation,
recordTurn: host.recordTurn,
alertAck: alertAck(host),
})

/**
Expand Down
1 change: 1 addition & 0 deletions apps/chat-bot/src/relay/settle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ const ports = (
announceUnlinked: Effect.succeed(false),
ownsConversation: () => Effect.succeed(false),
rememberConversation: () => Effect.void,
alertAck: () => Effect.succeed({ kind: "unavailable" }),
// Cloned, as Durable Object storage does: nothing the fiber holds survives it.
recordTurn: (checkpoint) =>
Effect.suspend(() => {
Expand Down
Loading
Loading