From 29c2aa946b801389208aa2032f77a75e0e7b920e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 01:02:23 +0100 Subject: [PATCH 01/28] feat(alerts): alert silences that hold back notifications A silence matches rules by rule id, tag, service and environment (fields AND, values OR; services and environments must cover the rule's whole scope or the incident's group). While one is in force, rules still evaluate and incidents still open and resolve, but deliveries are written as `silenced` and the phone push is skipped. The incident records the silence, and the first breaching check after it ends sends the held-back page once. A resolve during a silence reaches only destinations that were paged before it. Adds the alert_silences table, AlertSilencesService, and /v2/alerts/silences (list, retrieve, create, update, end) with audit events. --- .../mcp/tools/__tests__/alert-tools.test.ts | 2 + .../api/src/routes/v2/alert-incidents.http.ts | 4 +- apps/api/src/routes/v2/alert-silences.http.ts | 182 + apps/api/src/routes/v2/alerts.http.test.ts | 87 +- .../routes/v2/phase1-resources.http.test.ts | 2 + apps/api/src/routes/v2/v2-test-support.ts | 4 + apps/api/src/runtime/http-graph.ts | 2 + apps/api/src/runtime/service-graph.ts | 2 + apps/web/src/lab/alerts-fixture.ts | 2 + .../src/observability/AlertingMetrics.ts | 10 + .../services/alerts/AlertReadModelsService.ts | 2 + .../services/alerts/AlertSilencesService.ts | 333 + .../alerts/AlertsService.silences.test.ts | 332 + .../src/services/alerts/AlertsService.ts | 229 +- .../services/alerts/alert-silence-store.ts | 75 + .../alerts/alerts-service-test-support.ts | 12 +- .../migration.sql | 26 + .../snapshot.json | 18975 ++++++++++++++++ packages/db/src/tables/alerts.ts | 41 + packages/db/src/tables/index.ts | 2 + packages/domain/src/audit-actions.ts | 5 + .../domain/src/http/alert-silences.test.ts | 105 + packages/domain/src/http/alert-silences.ts | 164 + packages/domain/src/http/alerts.ts | 14 +- packages/domain/src/http/index.ts | 1 + .../domain/src/http/v2/alert-incidents.ts | 11 + packages/domain/src/http/v2/alert-silences.ts | 241 + packages/domain/src/http/v2/api.ts | 2 + packages/domain/src/http/v2/index.ts | 1 + packages/domain/src/http/v2/openapi.test.ts | 5 + packages/domain/src/http/v2/public-id.ts | 1 + .../domain/src/http/v2/v2-contract.test.ts | 2 + packages/primitives/src/index.ts | 3 + packages/web-data/src/alerts/form-utils.ts | 1 + packages/web-data/src/alerts/headroom.test.ts | 2 + .../src/alerts/incident-timeline.test.ts | 2 + .../src/alerts/overview-derive.test.ts | 2 + packages/web-data/src/alerts/overview.test.ts | 2 + .../src/alerts/preview-insights.test.ts | 2 + packages/web-data/src/collections/alerts.ts | 6 + 40 files changed, 20864 insertions(+), 32 deletions(-) create mode 100644 apps/api/src/routes/v2/alert-silences.http.ts create mode 100644 packages/backend/src/services/alerts/AlertSilencesService.ts create mode 100644 packages/backend/src/services/alerts/AlertsService.silences.test.ts create mode 100644 packages/backend/src/services/alerts/alert-silence-store.ts create mode 100644 packages/db/drizzle/20261105234448_alert_silences/migration.sql create mode 100644 packages/db/drizzle/20261105234448_alert_silences/snapshot.json create mode 100644 packages/domain/src/http/alert-silences.test.ts create mode 100644 packages/domain/src/http/alert-silences.ts create mode 100644 packages/domain/src/http/v2/alert-silences.ts diff --git a/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts index a0b526b6ae..03a1465d55 100644 --- a/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts @@ -136,6 +136,8 @@ const incident = (overrides: Record) => ({ holdReason: null, heldSince: null, errorIssueId: null, + silenceId: null, + silencedAt: null, ...overrides, }) diff --git a/apps/api/src/routes/v2/alert-incidents.http.ts b/apps/api/src/routes/v2/alert-incidents.http.ts index dd7bbf2538..2252349a24 100644 --- a/apps/api/src/routes/v2/alert-incidents.http.ts +++ b/apps/api/src/routes/v2/alert-incidents.http.ts @@ -2,7 +2,7 @@ import { HttpApiBuilder } from "effect/http-api" import type { AlertIncidentDocument } from "@maple/domain/http" import { CurrentTenant } from "@maple/domain/http" import type { V2AlertIncident } from "@maple/domain/http/v2" -import { MapleApiV2, paginateOffsetQuery } from "@maple/domain/http/v2" +import { MapleApiV2, paginateOffsetQuery, timestampOrNull } from "@maple/domain/http/v2" import { Effect, Array as Arr } from "effect" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" @@ -30,6 +30,8 @@ const toV2Incident = (doc: AlertIncidentDocument): V2AlertIncident => ({ hold_reason: doc.holdReason, held_since: doc.heldSince, error_issue_id: doc.errorIssueId, + silence_id: doc.silenceId, + silenced_at: timestampOrNull(doc.silencedAt), }) export const HttpV2AlertIncidentsLive = HttpApiBuilder.group(MapleApiV2, "alertIncidents", (handlers) => diff --git a/apps/api/src/routes/v2/alert-silences.http.ts b/apps/api/src/routes/v2/alert-silences.http.ts new file mode 100644 index 0000000000..be175c3e25 --- /dev/null +++ b/apps/api/src/routes/v2/alert-silences.http.ts @@ -0,0 +1,182 @@ +import { HttpApiBuilder } from "effect/http-api" +import { + AlertSilenceCreateRequest, + AlertSilenceUpdateRequest, + CurrentTenant, + IsoDateTimeString, + type AlertSilenceDocument, + type AlertSilenceMatchers, +} from "@maple/domain/http" +import type { + V2AlertSilence, + V2AlertSilenceMatchers, + V2AlertSilenceUpdateParams, +} from "@maple/domain/http/v2" +import { MapleApiV2, paginateOffsetQuery, timestamp, V2ParameterInvalid } from "@maple/domain/http/v2" +import { Array as Arr, Clock, DateTime, Effect, Schema } from "effect" +import { auditDiff } from "@/routes/v2/audit-changes" +import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService" +import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" +import { timestampMs } from "@maple/backend/platform/time" + +const decodeIso = Schema.decodeUnknownSync(IsoDateTimeString) + +const silenceAuditDiff = auditDiff({ + fields: ["reason", "matchers", "starts_at", "ends_at"], +}) + +const toMatchers = (matchers: V2AlertSilenceMatchers): AlertSilenceMatchers => ({ + ...(matchers.rule_ids === undefined ? undefined : { ruleIds: matchers.rule_ids }), + ...(matchers.tags === undefined ? undefined : { tags: matchers.tags }), + ...(matchers.services === undefined ? undefined : { services: matchers.services }), + ...(matchers.environments === undefined ? undefined : { environments: matchers.environments }), +}) + +const fromMatchers = (matchers: AlertSilenceMatchers): V2AlertSilenceMatchers => ({ + ...(matchers.ruleIds === undefined ? undefined : { rule_ids: matchers.ruleIds }), + ...(matchers.tags === undefined ? undefined : { tags: matchers.tags }), + ...(matchers.services === undefined ? undefined : { services: matchers.services }), + ...(matchers.environments === undefined ? undefined : { environments: matchers.environments }), +}) + +const hasMatcher = (matchers: V2AlertSilenceMatchers) => + (matchers.rule_ids?.length ?? 0) + + (matchers.tags?.length ?? 0) + + (matchers.services?.length ?? 0) + + (matchers.environments?.length ?? 0) > + 0 + +const requireMatcher = (matchers: V2AlertSilenceMatchers) => + hasMatcher(matchers) + ? Effect.void + : Effect.fail( + V2ParameterInvalid.make("A silence needs at least one rule, tag, service or environment.", { + param: "matchers", + }), + ) + +export const toV2Silence = (doc: AlertSilenceDocument): V2AlertSilence => ({ + id: doc.id, + object: "alert_silence", + reason: doc.reason, + matchers: fromMatchers(doc.matchers), + status: doc.status, + starts_at: timestamp(doc.startsAt), + ends_at: timestamp(doc.endsAt), + created_by: doc.createdBy, + created_at: timestamp(doc.createdAt), + updated_at: timestamp(doc.updatedAt), +}) + +export const HttpV2AlertSilencesLive = HttpApiBuilder.group(MapleApiV2, "alertSilences", (handlers) => + Effect.gen(function* () { + const silences = yield* AlertSilencesService + + return handlers + .handle("list", ({ query }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const page = yield* paginateOffsetQuery(query, ({ limit, offset }) => + silences + .list(tenant.orgId, { + ...(query.status === undefined ? undefined : { status: query.status }), + limit, + offset, + }) + .pipe(Effect.map((response) => Arr.map(response.silences, toV2Silence))), + ) + return { object: "list" as const, ...page } + }), + ) + .handle("retrieve", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const silence = yield* silences.get(tenant.orgId, params.id) + return toV2Silence(silence) + }), + ) + .handle("create", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireMatcher(payload.matchers) + if ((payload.ends_at === undefined) === (payload.duration_minutes === undefined)) { + return yield* V2ParameterInvalid.make( + "Give exactly one of ends_at and duration_minutes.", + { + param: payload.ends_at === undefined ? "ends_at" : "duration_minutes", + }, + ) + } + const startsAtMs = + payload.starts_at === undefined + ? yield* Clock.currentTimeMillis + : timestampMs(payload.starts_at) + const endsAt = + payload.ends_at ?? + DateTime.formatIso( + DateTime.makeUnsafe(startsAtMs + (payload.duration_minutes ?? 0) * 60_000), + ) + const created = yield* silences.create( + tenant.orgId, + tenant.userId, + tenant.roles, + new AlertSilenceCreateRequest({ + reason: payload.reason, + matchers: toMatchers(payload.matchers), + ...(payload.starts_at === undefined + ? undefined + : { startsAt: decodeIso(payload.starts_at) }), + endsAt: decodeIso(endsAt), + }), + ) + yield* recordHttpAudit("alert_silence.created", { + resourceId: created.id, + metadata: { reason: created.reason, endsAt: created.endsAt }, + }) + return toV2Silence(created) + }), + ) + .handle("update", ({ params, payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + if (payload.matchers !== undefined) yield* requireMatcher(payload.matchers) + const current = yield* silences.get(tenant.orgId, params.id) + const updated = yield* silences.update( + tenant.orgId, + tenant.userId, + tenant.roles, + params.id, + new AlertSilenceUpdateRequest({ + ...(payload.reason === undefined ? undefined : { reason: payload.reason }), + ...(payload.matchers === undefined + ? undefined + : { matchers: toMatchers(payload.matchers) }), + ...(payload.starts_at === undefined + ? undefined + : { startsAt: decodeIso(payload.starts_at) }), + ...(payload.ends_at === undefined + ? undefined + : { endsAt: decodeIso(payload.ends_at) }), + }), + ) + yield* recordHttpAudit("alert_silence.updated", { + resourceId: updated.id, + changes: silenceAuditDiff(payload, toV2Silence(current), toV2Silence(updated)), + metadata: { reason: updated.reason }, + }) + return toV2Silence(updated) + }), + ) + .handle("end", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const ended = yield* silences.end(tenant.orgId, tenant.userId, tenant.roles, params.id) + yield* recordHttpAudit("alert_silence.ended", { + resourceId: ended.id, + metadata: { reason: ended.reason }, + }) + return toV2Silence(ended) + }), + ) + }), +) diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index 0c49db26a0..9ab934108a 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -19,12 +19,13 @@ import { FetchHttpClient, HttpRouter } from "effect/http" import { HttpApiBuilder } from "effect/http-api" import { AlertMemberDirectoryUnavailableError, + AlertSilenceId, IntegrationsRevokedError, OrgId, UserId, WarehouseQuotaExceededError, } from "@maple/domain/http" -import { MapleApiV2 } from "@maple/domain/http/v2" +import { AlertSilencePublicId, MapleApiV2 } from "@maple/domain/http/v2" import { BucketCacheService } from "@maple/query-engine/caching" import { EdgeCacheService } from "@maple/cache" import { CacheBackendLive } from "@maple/backend/platform/CacheBackendLive" @@ -1181,4 +1182,88 @@ describe("v2 alerts over HTTP", () => { await harness.dispose() }) + + it("creates, lists, extends and ends an alert silence", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(["alerts:write"]) + const { rule } = await createWebhookAndRule(harness, key.secret) + + const created = await harness.request("POST", "/v2/alerts/silences", key.secret, { + reason: "Deploying checkout v2.3", + matchers: { rule_ids: [rule.body.id], services: ["checkout"] }, + duration_minutes: 60, + }) + expect(created.status).toBe(200) + expect(created.body).toMatchObject({ + object: "alert_silence", + status: "active", + matchers: { rule_ids: [rule.body.id], services: ["checkout"] }, + }) + expect(created.body.id).toMatch(/^sil_/) + expect(Date.parse(created.body.ends_at) - Date.parse(created.body.starts_at)).toBe(60 * 60_000) + const id: string = created.body.id + + const active = await harness.request("GET", "/v2/alerts/silences?status=active", key.secret) + expect(active.body.data.map((silence: { id: string }) => silence.id)).toEqual([id]) + + const endsAt = new Date(Date.parse(created.body.ends_at) + 60 * 60_000).toISOString() + const extended = await harness.request("PATCH", `/v2/alerts/silences/${id}`, key.secret, { + ends_at: endsAt, + }) + expect(extended.status).toBe(200) + expect(extended.body.ends_at).toBe(endsAt) + + const ended = await harness.request("POST", `/v2/alerts/silences/${id}/end`, key.secret) + expect(ended.status).toBe(200) + expect(ended.body.status).toBe("ended") + const stillActive = await harness.request("GET", "/v2/alerts/silences?status=active", key.secret) + expect(stillActive.body.data).toEqual([]) + + const unknownId = Schema.encodeSync(AlertSilencePublicId)( + Schema.decodeSync(AlertSilenceId)("0b8f7c1e-2f61-4f1e-9d2a-3c4b5d6e7f80"), + ) + const missing = await harness.request("GET", `/v2/alerts/silences/${unknownId}`, key.secret) + expect(missing.status).toBe(404) + + await harness.dispose() + }) + + it("rejects a silence without a matcher, an end or a sensible window", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(["alerts:write"]) + const post = (body: unknown) => harness.request("POST", "/v2/alerts/silences", key.secret, body) + + const noMatcher = await post({ reason: "x", matchers: {}, duration_minutes: 10 }) + const noEnd = await post({ reason: "x", matchers: { tags: ["db"] } }) + const both = await post({ + reason: "x", + matchers: { tags: ["db"] }, + duration_minutes: 10, + ends_at: new Date(Date.now() + 60_000).toISOString(), + }) + const tooLong = await post({ + reason: "x", + matchers: { tags: ["db"] }, + duration_minutes: 31 * 24 * 60, + }) + const ended = await post({ + reason: "x", + matchers: { tags: ["db"] }, + ends_at: new Date(Date.now() - 60_000).toISOString(), + }) + for (const response of [noMatcher, noEnd, both, tooLong, ended]) { + expect(response.status).toBe(400) + expect(response.body.error.type).toBe("invalid_request_error") + } + + const readOnly = await harness.bootstrapKey(["alerts:read"]) + const denied = await harness.request("POST", "/v2/alerts/silences", readOnly.secret, { + reason: "x", + matchers: { tags: ["db"] }, + duration_minutes: 10, + }) + expect(denied.status).toBe(403) + + await harness.dispose() + }) }) diff --git a/apps/api/src/routes/v2/phase1-resources.http.test.ts b/apps/api/src/routes/v2/phase1-resources.http.test.ts index edcc50240d..a5cde52268 100644 --- a/apps/api/src/routes/v2/phase1-resources.http.test.ts +++ b/apps/api/src/routes/v2/phase1-resources.http.test.ts @@ -201,6 +201,8 @@ const anomalyFixture = new AnomalyIncidentDocument({ deploymentEnv: "production", fingerprintHash: null, errorIssueId: null, + silenceId: null, + silencedAt: null, status: "open", severity: "critical", openedValue: 0.12, diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index d6f0dd57d6..eb7a40d5a8 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -6,6 +6,7 @@ import { AlertsService } from "@maple/backend/services/alerts/AlertsService" import { AlertDestinationsService } from "@maple/backend/services/alerts/AlertDestinationsService" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" +import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" import { AnomalyDetectionService } from "@maple/backend/services/alerts/AnomalyDetectionService" import { ErrorsService } from "@maple/backend/services/errors/ErrorsService" import { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService" @@ -45,6 +46,7 @@ import { QueryEngineService } from "@maple/backend/services/warehouse/QueryEngin import { HttpV2AlertDeliveriesLive } from "./alert-deliveries.http" import { HttpV2AlertDestinationsLive } from "./alert-destinations.http" import { HttpV2AlertIncidentsLive } from "./alert-incidents.http" +import { HttpV2AlertSilencesLive } from "./alert-silences.http" import { HttpV2AlertRulesLive } from "./alert-rules.http" import { HttpV2ApiKeysLive } from "./api-keys.http" import { HttpV2AttributeMappingsLive } from "./attribute-mappings.http" @@ -182,6 +184,8 @@ const v2GroupLayersExceptOnboardingChecklist = ( HttpV2AlertRulesLive, HttpV2AlertDestinationsLive, HttpV2AlertIncidentsLive, + // Real service: it needs only the Database every harness already provides. + HttpV2AlertSilencesLive.pipe(Layer.provide(AlertSilencesService.layer)), HttpV2IngestKeysLive, HttpV2SourcesLive, HttpV2ErrorIssuesLive, diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index 7502760091..c9df53bd41 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -45,6 +45,7 @@ import { ClerkWebhookRouter } from "@/routes/webhooks/clerk.http" import { HttpV2AlertDeliveriesLive } from "@/routes/v2/alert-deliveries.http" import { HttpV2AlertDestinationsLive } from "@/routes/v2/alert-destinations.http" import { HttpV2AlertIncidentsLive } from "@/routes/v2/alert-incidents.http" +import { HttpV2AlertSilencesLive } from "@/routes/v2/alert-silences.http" import { HttpV2AlertRulesLive } from "@/routes/v2/alert-rules.http" import { HttpV2AnomaliesLive } from "@/routes/v2/anomalies.http" import { HttpV2ApiKeysLive } from "@/routes/v2/api-keys.http" @@ -182,6 +183,7 @@ const ApiV2Routes = HttpApiBuilder.layer(MapleApiV2).pipe( HttpV2AlertRulesLive, HttpV2AlertDestinationsLive, HttpV2AlertIncidentsLive, + HttpV2AlertSilencesLive, HttpV2IngestKeysLive, HttpV2SourcesLive, HttpV2ChatIntegrationsLive, diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index 53c2b06b83..b664de4a75 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -10,6 +10,7 @@ import { AlertsService } from "@maple/backend/services/alerts/AlertsService" import { AlertDestinationsService } from "@maple/backend/services/alerts/AlertDestinationsService" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" +import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" import { AnomalyDetectionService } from "@maple/backend/services/alerts/AnomalyDetectionService" import { PlanetScaleOAuthService } from "@maple/backend/services/auth/PlanetScaleOAuthService" @@ -176,6 +177,7 @@ export const HttpServicesLive = Layer.mergeAll( AlertDestinationsService.layer, AlertReadModelsService.layer, AlertRulesService.layer, + AlertSilencesService.layer, AlertsService.layer, AnomalyDetectionService.layer, AiTriageService.layer, diff --git a/apps/web/src/lab/alerts-fixture.ts b/apps/web/src/lab/alerts-fixture.ts index 6931236499..031917ff9e 100644 --- a/apps/web/src/lab/alerts-fixture.ts +++ b/apps/web/src/lab/alerts-fixture.ts @@ -543,6 +543,8 @@ const incidentFrom = (spec: IncidentSpec, rule: AlertRuleDocument, nowMs: number holdReason: spec.held ? "volume_collapsed" : null, heldSince: spec.held ? iso(nowMs - 58 * MIN) : null, errorIssueId: null, + silenceId: null, + silencedAt: null, }) } diff --git a/packages/backend/src/observability/AlertingMetrics.ts b/packages/backend/src/observability/AlertingMetrics.ts index 8a1e7095ab..7a1c6ffc4d 100644 --- a/packages/backend/src/observability/AlertingMetrics.ts +++ b/packages/backend/src/observability/AlertingMetrics.ts @@ -59,6 +59,16 @@ export const incidentsResolvedAfterHoldTotal = Metric.counter( }, ) +export const notificationsSilencedTotal = Metric.counter("alerting.notifications_silenced_total", { + description: "Incident notifications an alert silence held back", + incremental: true, +}) + +export const silenceCatchUpsTotal = Metric.counter("alerting.silence_catch_ups_total", { + description: "Notifications sent after a silence ended on a still-open incident", + incremental: true, +}) + export const deliveriesAttemptedTotal = Metric.counter("alerting.deliveries_attempted_total", { description: "Total number of delivery attempts", incremental: true, diff --git a/packages/backend/src/services/alerts/AlertReadModelsService.ts b/packages/backend/src/services/alerts/AlertReadModelsService.ts index 7240673eb1..061a01d540 100644 --- a/packages/backend/src/services/alerts/AlertReadModelsService.ts +++ b/packages/backend/src/services/alerts/AlertReadModelsService.ts @@ -178,6 +178,8 @@ const rowToIncidentDocument = (row: AlertIncidentRow) => holdReason: row.holdReason != null ? decodeAlertIncidentHoldReasonSync(row.holdReason) : null, heldSince: toIso(row.heldSince), errorIssueId: row.errorIssueId != null ? decodeErrorIssueIdSync(row.errorIssueId) : null, + silenceId: row.silenceId, + silencedAt: toIso(row.silencedAt), }) /** An optional request bound, or `null` when it does not parse (the filter is then skipped). */ diff --git a/packages/backend/src/services/alerts/AlertSilencesService.ts b/packages/backend/src/services/alerts/AlertSilencesService.ts new file mode 100644 index 0000000000..eeb29368dc --- /dev/null +++ b/packages/backend/src/services/alerts/AlertSilencesService.ts @@ -0,0 +1,333 @@ +import { + AlertForbiddenError, + AlertPersistenceError, + AlertSilenceId as AlertSilenceIdSchema, + AlertSilenceNotFoundError, + AlertSilencesListResponse, + AlertValidationError, + MAX_ALERT_SILENCE_DURATION_MS, + type AlertSilenceCreateRequest, + type AlertSilenceDocument, + type AlertSilenceId, + type AlertSilenceMatchers, + type AlertSilenceStatus, + type AlertSilenceUpdateRequest, + type OrgId, + type RoleName, + type UserId, +} from "@maple/domain/http" +import * as PG from "@maple-dev/effect-orm/postgres" +import { AlertRules, AlertSilences, type AlertSilenceRow } from "@maple/db/tables" +import { Array as Arr, Context, Effect, Layer, Schema } from "effect" +import { Database } from "@maple/backend/platform/DatabaseLive" +import { makeDbExecute } from "@maple/backend/platform/db-execute" +import { timestampMs } from "@maple/backend/platform/time" +import * as Policy from "@maple/domain/policy" +import { makePersistenceError } from "./alert-persistence" +import { AlertRuntime } from "./AlertRuntime" +import { selectSilences, silenceRowToDocument, type StoredSilenceRow } from "./alert-silence-store" + +/** Scheduled and active silences an org may hold at once. */ +const MAX_OPEN_SILENCES_PER_ORG = 200 +/** A start this far in the past still reads as "now" (a client clock a little behind). */ +const START_SKEW_MS = 5 * 60_000 + +const decodeSilenceIdSync = Schema.decodeUnknownSync(AlertSilenceIdSchema) + +export interface ListAlertSilencesOptions { + readonly status?: AlertSilenceStatus + readonly limit?: number + readonly offset?: number +} + +export interface AlertSilencesServiceApi { + readonly list: ( + orgId: OrgId, + options?: ListAlertSilencesOptions, + ) => Effect.Effect + readonly get: ( + orgId: OrgId, + silenceId: AlertSilenceId, + ) => Effect.Effect + readonly create: ( + orgId: OrgId, + userId: UserId, + roles: ReadonlyArray, + request: AlertSilenceCreateRequest, + ) => Effect.Effect< + AlertSilenceDocument, + AlertForbiddenError | AlertValidationError | AlertPersistenceError + > + readonly update: ( + orgId: OrgId, + userId: UserId, + roles: ReadonlyArray, + silenceId: AlertSilenceId, + request: AlertSilenceUpdateRequest, + ) => Effect.Effect< + AlertSilenceDocument, + AlertForbiddenError | AlertValidationError | AlertPersistenceError | AlertSilenceNotFoundError + > + /** Ends a silence now; a scheduled one is cancelled. Rows are kept so history can name them. */ + readonly end: ( + orgId: OrgId, + userId: UserId, + roles: ReadonlyArray, + silenceId: AlertSilenceId, + ) => Effect.Effect< + AlertSilenceDocument, + AlertForbiddenError | AlertPersistenceError | AlertSilenceNotFoundError + > +} + +const validationError = (message: string) => new AlertValidationError({ message, details: [message] }) + +export class AlertSilencesService extends Context.Service()( + "@maple/backend/services/alerts/AlertSilencesService", + { + make: Effect.gen(function* () { + const database = yield* Database + const runtime = yield* AlertRuntime + const dbExecute = makeDbExecute(database, "AlertSilencesService", makePersistenceError) + + const requireManage = (roles: ReadonlyArray) => + Policy.check( + roles, + "alerts:manage", + (denial) => + new AlertForbiddenError({ + message: denial.message, + ...(roles.length > 0 ? { roles: [...roles] } : undefined), + }), + ) + + const toDocument = (row: StoredSilenceRow, nowMs: number) => + Effect.fromOption(silenceRowToDocument(row, nowMs)).pipe( + Effect.mapError( + () => + new AlertPersistenceError({ + message: "Stored alert silence matchers are invalid", + }), + ), + ) + + const findRow = Effect.fn("AlertSilencesService.findRow")(function* ( + orgId: OrgId, + silenceId: AlertSilenceId, + ) { + const rows = yield* dbExecute((db) => + db.run( + selectSilences() + .where(($) => [$.orgId.eq(orgId), $.id.eq(silenceId)]) + .limit(1), + ), + ) + const row = rows[0] + if (row === undefined) { + return yield* new AlertSilenceNotFoundError({ + message: "Alert silence not found", + silenceId, + }) + } + return row + }) + + /** Every rule id must belong to the org, so a typo cannot silence nothing while looking fine. */ + const requireRuleIds = Effect.fn("AlertSilencesService.requireRuleIds")(function* ( + orgId: OrgId, + matchers: AlertSilenceMatchers, + ) { + const ruleIds = Arr.dedupe(matchers.ruleIds ?? []) + if (ruleIds.length === 0) return + const found = yield* dbExecute((db) => + db.run( + PG.from(AlertRules) + .select("id") + .where(($) => [$.orgId.eq(orgId), $.id.in_(...ruleIds)]), + ), + ) + const known = new Set(found.map((row) => row.id)) + const missing = ruleIds.filter((id) => !known.has(id)) + if (missing.length > 0) { + return yield* new AlertValidationError({ + message: "Unknown alert rule in silence matchers", + details: missing.map((id) => `Alert rule ${id} does not exist`), + }) + } + }) + + const validateWindow = (startsAtMs: number, endsAtMs: number, nowMs: number) => { + if (endsAtMs <= startsAtMs) + return Effect.fail(validationError("endsAt must be after startsAt")) + if (endsAtMs <= nowMs) return Effect.fail(validationError("endsAt must be in the future")) + if (endsAtMs - startsAtMs > MAX_ALERT_SILENCE_DURATION_MS) + return Effect.fail(validationError("A silence can last at most 30 days")) + return Effect.void + } + + const list = Effect.fn("AlertSilencesService.list")(function* ( + orgId: OrgId, + options: ListAlertSilencesOptions = {}, + ) { + const nowMs = yield* runtime.now + const status = options.status + const rows = yield* dbExecute((db) => + db.run( + selectSilences() + .where(($) => [ + $.orgId.eq(orgId), + ...(status === "active" ? [$.startsAt.lte(nowMs), $.endsAt.gt(nowMs)] : []), + ...(status === "scheduled" ? [$.startsAt.gt(nowMs)] : []), + ...(status === "ended" ? [$.endsAt.lte(nowMs)] : []), + ]) + .orderBy(["endsAt", "desc"], ["id", "asc"]) + .limit(options.limit ?? 500) + .offset(options.offset ?? 0), + ), + ) + const silences = yield* Effect.forEach(rows, (row) => toDocument(row, nowMs)) + return new AlertSilencesListResponse({ silences }) + }) + + const get = Effect.fn("AlertSilencesService.get")(function* ( + orgId: OrgId, + silenceId: AlertSilenceId, + ) { + const row = yield* findRow(orgId, silenceId) + const nowMs = yield* runtime.now + return yield* toDocument(row, nowMs) + }) + + const create = Effect.fn("AlertSilencesService.create")(function* ( + orgId: OrgId, + userId: UserId, + roles: ReadonlyArray, + request: AlertSilenceCreateRequest, + ) { + yield* requireManage(roles) + const nowMs = yield* runtime.now + const requestedStart = request.startsAt === undefined ? nowMs : timestampMs(request.startsAt) + if (requestedStart < nowMs - START_SKEW_MS) + return yield* validationError("startsAt cannot be in the past") + const startsAtMs = Math.max(requestedStart, nowMs) + const endsAtMs = timestampMs(request.endsAt) + yield* validateWindow(startsAtMs, endsAtMs, nowMs) + yield* requireRuleIds(orgId, request.matchers) + + const open = yield* dbExecute((db) => + db.run( + PG.from(AlertSilences) + .select("id") + .where(($) => [$.orgId.eq(orgId), $.endsAt.gt(nowMs)]) + .limit(MAX_OPEN_SILENCES_PER_ORG), + ), + ) + if (open.length >= MAX_OPEN_SILENCES_PER_ORG) + return yield* validationError( + `An organization can have at most ${MAX_OPEN_SILENCES_PER_ORG} active or scheduled silences`, + ) + + const row: AlertSilenceRow = { + id: decodeSilenceIdSync(runtime.makeUuid()), + orgId, + reason: request.reason, + matchersJson: request.matchers, + startsAt: startsAtMs, + endsAt: endsAtMs, + createdAt: nowMs, + updatedAt: nowMs, + createdBy: userId, + updatedBy: userId, + } + yield* dbExecute((db) => db.run(PG.insertInto(AlertSilences).values(row))) + yield* Effect.annotateCurrentSpan({ "maple.alert_silence.id": row.id }) + return yield* toDocument(row, nowMs) + }) + + const update = Effect.fn("AlertSilencesService.update")(function* ( + orgId: OrgId, + userId: UserId, + roles: ReadonlyArray, + silenceId: AlertSilenceId, + request: AlertSilenceUpdateRequest, + ) { + yield* requireManage(roles) + const nowMs = yield* runtime.now + const current = yield* findRow(orgId, silenceId) + if (current.endsAt <= nowMs) + return yield* validationError("An ended silence cannot be changed") + const started = current.startsAt <= nowMs + if (started && request.startsAt !== undefined) + return yield* validationError("A silence that has started cannot change its start") + + const startsAtMs = + request.startsAt === undefined + ? current.startsAt + : Math.max(timestampMs(request.startsAt), nowMs) + const endsAtMs = request.endsAt === undefined ? current.endsAt : timestampMs(request.endsAt) + yield* validateWindow(startsAtMs, endsAtMs, nowMs) + if (request.matchers !== undefined) yield* requireRuleIds(orgId, request.matchers) + + const next: StoredSilenceRow = { + ...current, + reason: request.reason ?? current.reason, + matchersJson: request.matchers ?? current.matchersJson, + startsAt: startsAtMs, + endsAt: endsAtMs, + updatedAt: nowMs, + updatedBy: userId, + } + yield* dbExecute((db) => + db.run( + PG.update(AlertSilences) + .set({ + reason: next.reason, + ...(request.matchers === undefined + ? undefined + : { matchersJson: request.matchers }), + startsAt: next.startsAt, + endsAt: next.endsAt, + updatedAt: next.updatedAt, + updatedBy: next.updatedBy, + }) + .where(($) => [$.orgId.eq(orgId), $.id.eq(silenceId)]), + ), + ) + return yield* toDocument(next, nowMs) + }) + + const end = Effect.fn("AlertSilencesService.end")(function* ( + orgId: OrgId, + userId: UserId, + roles: ReadonlyArray, + silenceId: AlertSilenceId, + ) { + yield* requireManage(roles) + const nowMs = yield* runtime.now + const current = yield* findRow(orgId, silenceId) + if (current.endsAt <= nowMs) return yield* toDocument(current, nowMs) + // A scheduled silence that never started collapses to an empty window at now. + const startsAt = Math.min(current.startsAt, nowMs) + const ended: StoredSilenceRow = { + ...current, + startsAt, + endsAt: nowMs, + updatedAt: nowMs, + updatedBy: userId, + } + yield* dbExecute((db) => + db.run( + PG.update(AlertSilences) + .set({ startsAt, endsAt: nowMs, updatedAt: nowMs, updatedBy: userId }) + .where(($) => [$.orgId.eq(orgId), $.id.eq(silenceId)]), + ), + ) + return yield* toDocument(ended, nowMs) + }) + + return { list, get, create, update, end } satisfies AlertSilencesServiceApi + }), + }, +) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend/src/services/alerts/AlertsService.silences.test.ts b/packages/backend/src/services/alerts/AlertsService.silences.test.ts new file mode 100644 index 0000000000..65e72e3bdc --- /dev/null +++ b/packages/backend/src/services/alerts/AlertsService.silences.test.ts @@ -0,0 +1,332 @@ +import { afterEach, assert, describe, it } from "@effect/vitest" +import { Clock, Duration, Effect, Exit, Array as Arr } from "effect" +import { TestClock } from "effect/testing" +import { + AlertForbiddenError, + AlertRuleId, + AlertValidationError, + AlertSilenceCreateRequest, + AlertSilenceUpdateRequest, + IsoDateTimeString, + type AlertSilenceMatchers, +} from "@maple/domain/http" +import { Schema } from "effect" +import { cleanupTestDbs, createTestDb, type TestDb } from "@maple/backend/platform/test-pglite" +import { AlertsService, type AlertsServiceApi } from "./AlertsService" +import { AlertSilencesService } from "./AlertSilencesService" +import { + DEFAULT_CLOCK_EPOCH_MS, + adminRoles, + asOrgId, + asUserId, + createErrorRateRule, + createWebhookDestination, + getError, + makeLayer, + makeWarehouseStub, + memberRoles, + tracesRow, +} from "./alerts-service-test-support" + +const trackedDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(trackedDbs)) + +const iso = Schema.decodeUnknownSync(IsoDateTimeString) +const asRuleId = Schema.decodeUnknownSync(AlertRuleId) +const at = (ms: number) => iso(new Date(ms).toISOString()) + +const errorRateRows = (errorRate: number) => [ + tracesRow({ + count: 200, + avgDuration: 40, + p50Duration: 20, + p95Duration: 120, + p99Duration: 240, + errorRate, + satisfiedCount: 180, + toleratingCount: 10, + apdexScore: 0.925, + }), +] + +const orgId = asOrgId("org_silences") +const userId = asUserId("user_silences") + +/** A breaching error-rate rule on `checkout` with one webhook, and a counter of the webhooks sent. */ +const setup = () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRateRows(10) as ReadonlyArray> } + const sent: Array = [] + const fetchImpl = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sent.push(new Headers(init?.headers).get("x-maple-event-type") ?? "") + return new Response("ok", { status: 200 }) + }) as typeof fetch + const layer = makeLayer(testDb, makeWarehouseStub(state), { fetch: fetchImpl }) + return { state, sent, layer } +} + +const tick = (alerts: AlertsServiceApi) => + Effect.gen(function* () { + yield* TestClock.adjust(Duration.minutes(1)) + yield* alerts.runSchedulerTick() + }) + +const ticks = (alerts: AlertsServiceApi, count: number) => + Effect.forEach(Arr.range(1, count), () => tick(alerts), { discard: true }) + +const silence = (matchers: AlertSilenceMatchers, minutes = 60) => + Effect.gen(function* () { + const silences = yield* AlertSilencesService + const now = yield* Clock.currentTimeMillis + return yield* silences.create( + orgId, + userId, + adminRoles, + new AlertSilenceCreateRequest({ + reason: "deploy checkout v2.3", + matchers, + endsAt: at(now + minutes * 60_000), + }), + ) + }) + +const deliveries = (alerts: AlertsServiceApi) => + alerts + .listDeliveryEvents(orgId) + .pipe( + Effect.map((page) => + Arr.map(Arr.reverse(page.events), (event) => `${event.eventType}:${event.status}`), + ), + ) + +const createRule = (alerts: AlertsServiceApi) => + Effect.gen(function* () { + const destination = yield* createWebhookDestination(alerts, orgId, userId) + return yield* createErrorRateRule(alerts, orgId, userId, destination.id) + }) + +describe("alert silences", () => { + it.effect("holds back the trigger and records it, without pushing", () => { + const { sent, layer } = setup() + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const alerts = yield* AlertsService + yield* createRule(alerts) + const created = yield* silence({ services: ["checkout"] }) + + yield* ticks(alerts, 2) + + const [incident] = (yield* alerts.listIncidents(orgId)).incidents + assert.strictEqual(incident?.status, "open") + assert.strictEqual(incident?.silenceId, created.id) + assert.isNotNull(incident?.silencedAt) + assert.deepStrictEqual(yield* deliveries(alerts), ["trigger:silenced"]) + assert.deepStrictEqual(sent, []) + }).pipe(Effect.provide(layer)) + }) + + it.effect("records one held-back page per silence, not one per reminder", () => { + const { sent, layer } = setup() + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const alerts = yield* AlertsService + yield* createRule(alerts) + yield* silence({ services: ["checkout"] }, 120) + + // The rule renotifies every 30 minutes; 70 minutes of breach would remind twice. + yield* ticks(alerts, 72) + + assert.deepStrictEqual(yield* deliveries(alerts), ["trigger:silenced"]) + assert.deepStrictEqual(sent, []) + }).pipe(Effect.provide(layer)) + }) + + it.effect("sends the held-back trigger once the silence ends on a still-open incident", () => { + const { sent, layer } = setup() + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const alerts = yield* AlertsService + const silences = yield* AlertSilencesService + yield* createRule(alerts) + const created = yield* silence({ services: ["checkout"] }) + yield* ticks(alerts, 2) + + yield* silences.end(orgId, userId, adminRoles, created.id) + yield* ticks(alerts, 3) + + const [incident] = (yield* alerts.listIncidents(orgId)).incidents + assert.deepStrictEqual(yield* deliveries(alerts), ["trigger:silenced", "trigger:success"]) + assert.deepStrictEqual(sent, ["trigger"]) + assert.isNull(incident?.silencedAt) + // The record of which silence held it back stays. + assert.strictEqual(incident?.silenceId, created.id) + }).pipe(Effect.provide(layer)) + }) + + it.effect("sends the all-clear to whoever was paged before the silence began", () => { + const { state, sent, layer } = setup() + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const alerts = yield* AlertsService + yield* createRule(alerts) + yield* ticks(alerts, 2) + yield* silence({ services: ["checkout"] }) + + state.tracesAggregateRows = errorRateRows(0.5) + yield* ticks(alerts, 2) + + assert.strictEqual((yield* alerts.listIncidents(orgId)).incidents[0]?.status, "resolved") + assert.deepStrictEqual(yield* deliveries(alerts), ["trigger:success", "resolve:success"]) + assert.deepStrictEqual(sent, ["trigger", "resolve"]) + }).pipe(Effect.provide(layer)) + }) + + it.effect("tells nobody about the resolve of an incident nobody was paged for", () => { + const { state, sent, layer } = setup() + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const alerts = yield* AlertsService + yield* createRule(alerts) + yield* silence({ services: ["checkout"] }) + yield* ticks(alerts, 2) + + state.tracesAggregateRows = errorRateRows(0.5) + yield* ticks(alerts, 2) + + assert.strictEqual((yield* alerts.listIncidents(orgId)).incidents[0]?.status, "resolved") + assert.deepStrictEqual(yield* deliveries(alerts), ["trigger:silenced", "resolve:silenced"]) + assert.deepStrictEqual(sent, []) + }).pipe(Effect.provide(layer)) + }) + + it.effect("stays quiet about a resolve after the silence when the held-back page never went out", () => { + const { state, sent, layer } = setup() + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const alerts = yield* AlertsService + const silences = yield* AlertSilencesService + yield* createRule(alerts) + const created = yield* silence({ services: ["checkout"] }) + yield* ticks(alerts, 2) + + // Healthy from here on, so no breaching check gets to send the catch-up. + state.tracesAggregateRows = errorRateRows(0.5) + yield* silences.end(orgId, userId, adminRoles, created.id) + yield* ticks(alerts, 2) + + assert.strictEqual((yield* alerts.listIncidents(orgId)).incidents[0]?.status, "resolved") + assert.deepStrictEqual(yield* deliveries(alerts), ["trigger:silenced", "resolve:silenced"]) + assert.deepStrictEqual(sent, []) + }).pipe(Effect.provide(layer)) + }) + + it.effect("does not touch a rule whose scope the silence only partly covers", () => { + const { sent, layer } = setup() + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const alerts = yield* AlertsService + yield* createRule(alerts) + yield* silence({ services: ["payments"] }) + yield* silence({ services: ["checkout"], environments: ["staging"] }) + + yield* ticks(alerts, 2) + + assert.isNull((yield* alerts.listIncidents(orgId)).incidents[0]?.silenceId ?? null) + assert.deepStrictEqual(sent, ["trigger"]) + }).pipe(Effect.provide(layer)) + }) + + it.effect("silences a rule by id until a scheduled start, then from it", () => { + const { sent, layer } = setup() + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const alerts = yield* AlertsService + const silences = yield* AlertSilencesService + const rule = yield* createRule(alerts) + const now = yield* Clock.currentTimeMillis + const scheduled = yield* silences.create( + orgId, + userId, + adminRoles, + new AlertSilenceCreateRequest({ + reason: "planned migration", + matchers: { ruleIds: [rule.id] }, + startsAt: at(now + 10 * 60_000), + endsAt: at(now + 70 * 60_000), + }), + ) + assert.strictEqual(scheduled.status, "scheduled") + + yield* ticks(alerts, 2) + assert.deepStrictEqual(sent, ["trigger"]) + assert.strictEqual((yield* silences.get(orgId, scheduled.id)).status, "scheduled") + + yield* ticks(alerts, 40) + // In force from minute 10: the reminder due at minute 32 is held back. + assert.deepStrictEqual(yield* deliveries(alerts), ["trigger:success", "renotify:silenced"]) + assert.deepStrictEqual(sent, ["trigger"]) + }).pipe(Effect.provide(layer)) + }) +}) + +describe("AlertSilencesService", () => { + it.effect("validates the window, the rule ids and the caller's role", () => { + const { layer } = setup() + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const silences = yield* AlertSilencesService + const now = DEFAULT_CLOCK_EPOCH_MS + const request = (endsAtMs: number, matchers: AlertSilenceMatchers = { tags: ["db"] }) => + new AlertSilenceCreateRequest({ reason: "maintenance", matchers, endsAt: at(endsAtMs) }) + + const past = yield* Effect.exit(silences.create(orgId, userId, adminRoles, request(now - 1))) + const tooLong = yield* Effect.exit( + silences.create(orgId, userId, adminRoles, request(now + 31 * 24 * 60 * 60_000)), + ) + const unknownRule = yield* Effect.exit( + silences.create( + orgId, + userId, + adminRoles, + request(now + 60_000, { ruleIds: [asRuleId("0b8f7c1e-2f61-4f1e-9d2a-3c4b5d6e7f80")] }), + ), + ) + const forbidden = yield* Effect.exit( + silences.create(orgId, userId, memberRoles, request(now + 60_000)), + ) + + assert.isTrue(Exit.isFailure(past) && Schema.is(AlertValidationError)(getError(past))) + assert.isTrue(Exit.isFailure(tooLong) && Schema.is(AlertValidationError)(getError(tooLong))) + assert.isTrue( + Exit.isFailure(unknownRule) && Schema.is(AlertValidationError)(getError(unknownRule)), + ) + assert.isTrue(Exit.isFailure(forbidden) && Schema.is(AlertForbiddenError)(getError(forbidden))) + }).pipe(Effect.provide(layer)) + }) + + it.effect("extends, then ends a silence, and lists it by status", () => { + const { layer } = setup() + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const silences = yield* AlertSilencesService + const created = yield* silence({ tags: ["db"] }) + const extended = yield* silences.update( + orgId, + userId, + adminRoles, + created.id, + new AlertSilenceUpdateRequest({ endsAt: at(DEFAULT_CLOCK_EPOCH_MS + 2 * 60 * 60_000) }), + ) + assert.strictEqual(Date.parse(extended.endsAt) - Date.parse(created.endsAt), 60 * 60_000) + + yield* TestClock.adjust(Duration.minutes(5)) + const ended = yield* silences.end(orgId, userId, adminRoles, created.id) + assert.strictEqual(ended.status, "ended") + assert.lengthOf((yield* silences.list(orgId, { status: "active" })).silences, 0) + assert.deepStrictEqual( + Arr.map((yield* silences.list(orgId, { status: "ended" })).silences, (s) => s.id), + [created.id], + ) + }).pipe(Effect.provide(layer)) + }) +}) diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index 132b1b55e2..758ce2b816 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -66,6 +66,7 @@ import { AlertRuleId, type AlertDestinationId, type AlertIncidentId, + type AlertSilenceId, type WarehouseQueryPathError, QueryEngineTimeoutError, type QueryEngineValidationError, @@ -158,6 +159,7 @@ import { LinearIssueTracker } from "./LinearIssueTracker" import { MobilePushService, type ResolvedAfterHold } from "@maple/backend/services/push/MobilePushService" import { AlertRuntime } from "./AlertRuntime" +import { firstMatchingSilence, selectActiveSilences, type MatchedSilence } from "./alert-silence-store" import { AlertDestinationsService, type AlertDestinationsServiceApi } from "./AlertDestinationsService" import { makeAlertDestinationDelivery, parseAlertDestinationEncryptionKey } from "./AlertDestinationDelivery" import { AlertReadModelsService, type AlertReadModelsServiceApi } from "./AlertReadModelsService" @@ -226,6 +228,8 @@ interface PreparedIncidentDelivery { readonly eventType: AlertEventTypeValue readonly payload: Record readonly scheduledAt: number + /** Set when a silence holds this delivery back: it is written `silenced` and never sent. */ + readonly silenceId: AlertSilenceId | null } /** @@ -1168,6 +1172,7 @@ export class AlertsService extends Context.Service orm.run( PG.insertInto(AlertDeliveryEvents) @@ -1180,7 +1185,8 @@ export class AlertsService extends Context.Service + ? yield* dbExecute((db) => db.run( PG.from(AlertDeliveryEvents) - .select("destinationId") + .select("destinationId", "status", "eventType") .where(($) => [$.orgId.eq(orgId), $.incidentId.eq(incident.id)]), ), - )).map((row) => row.destinationId) + ) : [] - const destinationIds = Arr.dedupe([...rule.destinationIds, ...priorDestinationIds]) + const destinationIds = Arr.dedupe([ + ...rule.destinationIds, + ...priorDeliveries.map((row) => row.destinationId), + ]) if (destinationIds.length === 0) return [] + // Under a silence, a resolve still reaches whoever was paged before it began. + const pagedDestinationIds = new Set( + priorDeliveries + .filter((row) => row.status === "success" && row.eventType !== "resolve") + .map((row) => row.destinationId), + ) + const silenceFor = (destinationId: AlertDestinationId): AlertSilenceId | null => + silenceId !== null && !pagedDestinationIds.has(destinationId) ? silenceId : null + const anySent = destinationIds.some((destinationId) => silenceFor(destinationId) === null) const rows = yield* dbExecute((db) => db.run( @@ -1395,7 +1414,7 @@ export class AlertsService extends Context.Service selectActiveSilences(db, orgId, atMs)) + const match = firstMatchingSilence(rows, { + ruleId: rule.id, + tags: rule.tags, + serviceNames: rule.serviceNames, + environments: rule.environments, + groupBy: rule.groupBy, + groupKey, + }) + yield* Effect.annotateCurrentSpan({ + "maple.alert.rule_id": rule.id, + "maple.alert_silence.active_count": rows.length, + ...(match === null ? undefined : { "maple.alert_silence.id": match.id }), + }) + return match + }) + const insertIncidentDeliveries = ( db: MapleDb, deliveries: ReadonlyArray, @@ -1504,6 +1548,7 @@ export class AlertsService extends Context.Service 0) yield* dbExecute((db) => insertIncidentDeliveries(db, deliveries)) + if (silence !== null) { + yield* dbExecute((db) => + db.run( + PG.update(AlertIncidents) + .set({ + silenceId: silence.id, + ...(eventType === "resolve" + ? undefined + : { silencedAt: incident.silencedAt ?? scheduledAt }), + }) + .where(($) => [$.orgId.eq(orgId), $.id.eq(incident.id)]), + ), + ) + } + if (silenceId !== null) { + yield* Metric.update(AlertingMetrics.notificationsSilencedTotal, 1) + yield* Effect.logInfo("Alert notification held back by a silence").pipe( + Effect.annotateLogs({ + ruleId: rule.id, + incidentId: incident.id, + silenceId, + eventType, + }), + ) + // The phone only hears the all-clear of a page it already got. + if (eventType !== "resolve" || incident.lastDeliveredEventType === null) return + } yield* pushIncidentNotification( orgId, rule, @@ -3106,6 +3185,12 @@ export class AlertsService extends Context.Service [$.id.eq(openIncident.id), $.status.eq("open")]) .returning("id"), @@ -3304,15 +3424,43 @@ export class AlertsService extends Context.Service [$.id.eq(openIncident.id), $.status.eq("open")]) @@ -3388,14 +3537,22 @@ export class AlertsService extends Context.Service [$.id.eq(incident.id), $.status.eq("open")]) @@ -3685,9 +3843,16 @@ export class AlertsService extends Context.Service [$.id.eq(incident.id), $.status.eq("open")]) @@ -3869,9 +4036,16 @@ export class AlertsService extends Context.Service row.endsAt) + +const iso = (ms: number) => new Date(ms).toISOString() + +/** A silence row with its matchers left undecoded, so an old or newer shape cannot fail the read. */ +export type StoredSilenceRow = Omit & { readonly matchersJson: unknown } + +export const selectSilences = () => + PG.from(AlertSilences).select(($) => ({ ...$, matchersJson: PG.undecoded($.matchersJson) })) + +/** A stored row the current schema cannot read matches nothing, rather than everything. */ +export const silenceRowMatchers = (row: StoredSilenceRow): Option.Option => + decodeMatchers(row.matchersJson) + +export const silenceRowToDocument = ( + row: StoredSilenceRow, + nowMs: number, +): Option.Option => + Option.map(silenceRowMatchers(row), (matchers) => + decodeDocument({ + id: row.id, + reason: row.reason, + matchers, + status: alertSilenceStatusAt({ startsAtMs: row.startsAt, endsAtMs: row.endsAt }, nowMs), + startsAt: iso(row.startsAt), + endsAt: iso(row.endsAt), + createdBy: row.createdBy, + createdAt: iso(row.createdAt), + updatedAt: iso(row.updatedAt), + }), + ) + +/** The org's silences in force at `atMs`, served by `alert_silences_org_ends_idx`. */ +export const selectActiveSilences = (db: MapleDb, orgId: OrgId, atMs: number) => + db.run(selectSilences().where(($) => [$.orgId.eq(orgId), $.endsAt.gt(atMs), $.startsAt.lte(atMs)])) + +export interface MatchedSilence { + readonly id: AlertSilenceId + readonly reason: string +} + +/** The first of `silences` that covers `target`; the earliest-ending wins so the record names the shortest hold. */ +export const firstMatchingSilence = ( + silences: ReadonlyArray, + target: AlertSilenceTarget, +): MatchedSilence | null => { + const matching = Arr.filter(silences, (row) => + Option.match(silenceRowMatchers(row), { + onNone: () => false, + onSome: (matchers) => alertSilenceMatches(matchers, target), + }), + ) + const first = Arr.head(Arr.sort(matching, silenceEndOrder)) + return Option.match(first, { + onNone: () => null, + onSome: (row) => ({ id: row.id, reason: row.reason }), + }) +} diff --git a/packages/backend/src/services/alerts/alerts-service-test-support.ts b/packages/backend/src/services/alerts/alerts-service-test-support.ts index 4bee096407..10a8d6494a 100644 --- a/packages/backend/src/services/alerts/alerts-service-test-support.ts +++ b/packages/backend/src/services/alerts/alerts-service-test-support.ts @@ -33,6 +33,7 @@ import { WarehouseQueryService } from "@maple/backend/services/warehouse/Warehou import { AlertRuntime, type AlertRuntimeApi, AlertsService, type AlertsServiceApi } from "./AlertsService" import { AlertDestinationsService } from "./AlertDestinationsService" import { AlertReadModelsService } from "./AlertReadModelsService" +import { AlertSilencesService } from "./AlertSilencesService" import { AlertRulesService } from "./AlertRulesService" import { UptimeAlertSource } from "@maple/backend/services/uptime/UptimeAlertSource" import { BucketCacheService } from "@maple/query-engine/caching" @@ -329,9 +330,16 @@ export const makeLayer = ( Layer.provide(UptimeAlertSource.layer.pipe(Layer.provide(databaseLive))), Layer.provide(chatSessions === undefined ? Layer.empty : Layer.succeed(ChatSessions, chatSessions)), ) - return Layer.mergeAll(alertDestinationsLive, alertReadModelsLive, alertRulesLive, alertsLive).pipe( - Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchImpl)), + const alertSilencesLive = AlertSilencesService.layer.pipe( + Layer.provide(Layer.mergeAll(databaseLive, runtimeLive)), ) + return Layer.mergeAll( + alertDestinationsLive, + alertReadModelsLive, + alertRulesLive, + alertSilencesLive, + alertsLive, + ).pipe(Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchImpl))) } export const asOrgId = Schema.decodeUnknownSync(OrgId) diff --git a/packages/db/drizzle/20261105234448_alert_silences/migration.sql b/packages/db/drizzle/20261105234448_alert_silences/migration.sql new file mode 100644 index 0000000000..9ac697c810 --- /dev/null +++ b/packages/db/drizzle/20261105234448_alert_silences/migration.sql @@ -0,0 +1,26 @@ +-- metadata +CREATE TABLE IF NOT EXISTS "alert_silences" ( + "id" text NOT NULL, + "org_id" text NOT NULL, + "reason" text NOT NULL, + "matchers_json" jsonb NOT NULL, + "starts_at" timestamp with time zone NOT NULL, + "ends_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + "created_by" text NOT NULL, + "updated_by" text NOT NULL, + CONSTRAINT "alert_silences_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +-- metadata +ALTER TABLE "alert_delivery_events" ADD COLUMN IF NOT EXISTS "silence_id" text; +--> statement-breakpoint +-- metadata +ALTER TABLE "alert_incidents" ADD COLUMN IF NOT EXISTS "silence_id" text; +--> statement-breakpoint +-- metadata +ALTER TABLE "alert_incidents" ADD COLUMN IF NOT EXISTS "silenced_at" timestamp with time zone; +--> statement-breakpoint +-- metadata +CREATE INDEX IF NOT EXISTS "alert_silences_org_ends_idx" ON "alert_silences" USING btree ("org_id", "ends_at"); diff --git a/packages/db/drizzle/20261105234448_alert_silences/snapshot.json b/packages/db/drizzle/20261105234448_alert_silences/snapshot.json new file mode 100644 index 0000000000..f23ce1a710 --- /dev/null +++ b/packages/db/drizzle/20261105234448_alert_silences/snapshot.json @@ -0,0 +1,18975 @@ +{ + "version": "1", + "dialect": "postgres", + "id": "d162fb6508b49b5c83fb09c8e85f9d6ed191ac00c988c828da610aaa8b69c93e", + "prevIds": [ + "5bec2645f5f8fd661f7ccf5046b1f82c678b054b45d008109dd6ea942fe6fac4" + ], + "entities": [ + { + "kind": "table", + "name": "actors", + "primaryKey": { + "name": "actors_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "agent_feedback", + "primaryKey": { + "name": "agent_feedback_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "ai_triage_settings", + "primaryKey": { + "name": "ai_triage_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "alert_delivery_events", + "primaryKey": { + "name": "alert_delivery_events_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_destinations", + "primaryKey": { + "name": "alert_destinations_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_incidents", + "primaryKey": { + "name": "alert_incidents_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_linear_issues", + "primaryKey": { + "name": "alert_linear_issues_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_rule_claims", + "primaryKey": { + "name": "alert_rule_claims_pkey", + "columns": [ + "rule_id" + ] + } + }, + { + "kind": "table", + "name": "alert_rule_states", + "primaryKey": { + "name": "alert_rule_states_org_id_rule_id_group_key_pk", + "columns": [ + "org_id", + "rule_id", + "group_key" + ] + } + }, + { + "kind": "table", + "name": "alert_rules", + "primaryKey": { + "name": "alert_rules_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_silences", + "primaryKey": { + "name": "alert_silences_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "anomaly_detector_settings", + "primaryKey": { + "name": "anomaly_detector_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "anomaly_detector_states", + "primaryKey": { + "name": "anomaly_detector_states_org_id_detector_key_pk", + "columns": [ + "org_id", + "detector_key" + ] + } + }, + { + "kind": "table", + "name": "anomaly_incidents", + "primaryKey": { + "name": "anomaly_incidents_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "api_keys", + "primaryKey": { + "name": "api_keys_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "aws_connections", + "primaryKey": { + "name": "aws_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cancellation_reviews", + "primaryKey": { + "name": "cancellation_reviews_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "chat_identities", + "primaryKey": { + "name": "chat_identities_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "chat_workspaces", + "primaryKey": { + "name": "chat_workspaces_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cli_device_authorizations", + "primaryKey": { + "name": "cli_device_authorizations_pkey", + "columns": [ + "device_code_hash" + ] + } + }, + { + "kind": "table", + "name": "cloudflare_analytics_state", + "primaryKey": { + "name": "cloudflare_analytics_state_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cloudflare_hyperdrive_configs", + "primaryKey": { + "name": "cloudflare_hyperdrive_configs_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cloudflare_logpush_connectors", + "primaryKey": { + "name": "cloudflare_logpush_connectors_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cloudflare_zone_settings", + "primaryKey": { + "name": "cloudflare_zone_settings_pkey", + "columns": [ + "org_id", + "account_id" + ] + } + }, + { + "kind": "table", + "name": "dashboard_shares", + "primaryKey": { + "name": "dashboard_shares_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + { + "kind": "table", + "name": "dashboard_versions", + "primaryKey": { + "name": "dashboard_versions_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + { + "kind": "table", + "name": "dashboards", + "primaryKey": { + "name": "dashboards_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + { + "kind": "table", + "name": "dependency_findings", + "primaryKey": { + "name": "dependency_findings_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "dependency_scan_matches", + "primaryKey": { + "name": "dependency_scan_matches_pkey", + "columns": [ + "scan_id", + "lockfile_path", + "ecosystem", + "package_name", + "version", + "advisory_id" + ] + } + }, + { + "kind": "table", + "name": "dependency_scans", + "primaryKey": { + "name": "dependency_scans_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "device_credentials", + "primaryKey": { + "name": "device_credentials_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "device_proof_replays", + "primaryKey": { + "name": "device_proof_replays_pkey", + "columns": [ + "key" + ] + } + }, + { + "kind": "table", + "name": "digest_subscriptions", + "primaryKey": { + "name": "digest_subscriptions_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_fingerprint_candidates", + "primaryKey": { + "name": "error_fingerprint_candidates_org_id_fingerprint_hash_pk", + "columns": [ + "org_id", + "fingerprint_hash" + ] + } + }, + { + "kind": "table", + "name": "error_incidents", + "primaryKey": { + "name": "error_incidents_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_events", + "primaryKey": { + "name": "error_issue_events_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_fingerprints", + "primaryKey": { + "name": "error_issue_fingerprints_org_id_fingerprint_hash_pk", + "columns": [ + "org_id", + "fingerprint_hash" + ] + } + }, + { + "kind": "table", + "name": "error_issue_pull_requests", + "primaryKey": { + "name": "error_issue_pull_requests_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_states", + "primaryKey": { + "name": "error_issue_states_org_id_issue_id_pk", + "columns": [ + "org_id", + "issue_id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_verifications", + "primaryKey": { + "name": "error_issue_verifications_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issues", + "primaryKey": { + "name": "error_issues_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_notification_deliveries", + "primaryKey": { + "name": "error_notification_deliveries_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_notification_policies", + "primaryKey": { + "name": "error_notification_policies_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "error_tick_states", + "primaryKey": { + "name": "error_tick_states_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "gcp_connectors", + "primaryKey": { + "name": "gcp_connectors_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "gcp_resources", + "primaryKey": { + "name": "gcp_resources_pkey", + "columns": [ + "connector_id", + "name" + ] + } + }, + { + "kind": "table", + "name": "investigations", + "primaryKey": { + "name": "investigations_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "issue_escalation_policies", + "primaryKey": { + "name": "issue_escalation_policies_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "issue_escalations", + "primaryKey": { + "name": "issue_escalations_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "live_activities", + "primaryKey": { + "name": "live_activities_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "mcp_oauth_authorizations", + "primaryKey": { + "name": "mcp_oauth_authorizations_pkey", + "columns": [ + "request_id_hash" + ] + } + }, + { + "kind": "table", + "name": "mcp_oauth_clients", + "primaryKey": { + "name": "mcp_oauth_clients_pkey", + "columns": [ + "client_id" + ] + } + }, + { + "kind": "table", + "name": "mcp_oauth_refresh_tokens", + "primaryKey": { + "name": "mcp_oauth_refresh_tokens_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "mobile_devices", + "primaryKey": { + "name": "mobile_devices_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "oauth_auth_states", + "primaryKey": { + "name": "oauth_auth_states_pkey", + "columns": [ + "state" + ] + } + }, + { + "kind": "table", + "name": "oauth_connections", + "primaryKey": { + "name": "oauth_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "org_billing_links", + "primaryKey": { + "name": "org_billing_links_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_clickhouse_schema_apply_runs", + "primaryKey": { + "name": "org_clickhouse_schema_apply_runs_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_clickhouse_settings", + "primaryKey": { + "name": "org_clickhouse_settings_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_attribute_mappings", + "primaryKey": { + "name": "org_ingest_attribute_mappings_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_keys", + "primaryKey": { + "name": "org_ingest_keys_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_rule_settings", + "primaryKey": { + "name": "org_ingest_rule_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_rule_stats", + "primaryKey": { + "name": "org_ingest_rule_stats_pkey", + "columns": [ + "org_id", + "rule_id", + "bucket", + "signal" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_rules", + "primaryKey": { + "name": "org_ingest_rules_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_sampling_policies", + "primaryKey": { + "name": "org_ingest_sampling_policies_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_onboarding_state", + "primaryKey": { + "name": "org_onboarding_state_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_openrouter_keys", + "primaryKey": { + "name": "org_openrouter_keys_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_recommendation_issues", + "primaryKey": { + "name": "org_recommendation_issues_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "org_support_channels", + "primaryKey": { + "name": "org_support_channels_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_connections", + "primaryKey": { + "name": "planetscale_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_databases", + "primaryKey": { + "name": "planetscale_databases_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_events", + "primaryKey": { + "name": "planetscale_events_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_issue_receipts", + "primaryKey": { + "name": "planetscale_issue_receipts_org_id_event_id_pk", + "columns": [ + "org_id", + "event_id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_poll_state", + "primaryKey": { + "name": "planetscale_poll_state_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_edits", + "primaryKey": { + "name": "pr_review_edits_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_finding_embeddings", + "primaryKey": { + "name": "pr_review_finding_embeddings_pkey", + "columns": [ + "finding_id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_findings", + "primaryKey": { + "name": "pr_review_findings_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_merge_steps", + "primaryKey": { + "name": "pr_review_merge_steps_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_replies", + "primaryKey": { + "name": "pr_review_replies_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_settings", + "primaryKey": { + "name": "pr_review_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "pr_reviews", + "primaryKey": { + "name": "pr_reviews_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "railway_connections", + "primaryKey": { + "name": "railway_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "railway_environments", + "primaryKey": { + "name": "railway_environments_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "scrape_target_checks", + "primaryKey": { + "name": "scrape_target_checks_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "scrape_targets", + "primaryKey": { + "name": "scrape_targets_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "security_audit_events", + "primaryKey": { + "name": "security_audit_events_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "security_audit_findings", + "primaryKey": { + "name": "security_audit_findings_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "security_audit_org_reservations", + "primaryKey": { + "name": "security_audit_org_reservations_pkey", + "columns": [ + "run_id", + "agent_id" + ] + } + }, + { + "kind": "table", + "name": "security_audit_org_settings", + "primaryKey": { + "name": "security_audit_org_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "security_audit_run_findings", + "primaryKey": { + "name": "security_audit_run_findings_pkey", + "columns": [ + "run_id", + "fingerprint" + ] + } + }, + { + "kind": "table", + "name": "security_audit_runs", + "primaryKey": { + "name": "security_audit_runs_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "security_audit_targets", + "primaryKey": { + "name": "security_audit_targets_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "security_audit_units", + "primaryKey": { + "name": "security_audit_units_pkey", + "columns": [ + "run_id", + "coverage_id" + ] + } + }, + { + "kind": "table", + "name": "sources", + "primaryKey": { + "name": "sources_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "status_page_cleanups", + "primaryKey": { + "name": "status_page_cleanups_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "status_page_components", + "primaryKey": { + "name": "status_page_components_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "status_page_incident_components", + "primaryKey": { + "name": "status_page_incident_components_pk", + "columns": [ + "incident_id", + "component_id" + ] + } + }, + { + "kind": "table", + "name": "status_page_incident_updates", + "primaryKey": { + "name": "status_page_incident_updates_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "status_page_incidents", + "primaryKey": { + "name": "status_page_incidents_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "status_pages", + "primaryKey": { + "name": "status_pages_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "supabase_connections", + "primaryKey": { + "name": "supabase_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "supabase_project_insights", + "primaryKey": { + "name": "supabase_project_insights_pkey", + "columns": [ + "project_id" + ] + } + }, + { + "kind": "table", + "name": "supabase_projects", + "primaryKey": { + "name": "supabase_projects_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "uptime_monitor_daily", + "primaryKey": { + "name": "uptime_monitor_daily_pk", + "columns": [ + "monitor_id", + "day" + ] + } + }, + { + "kind": "table", + "name": "uptime_monitor_states", + "primaryKey": { + "name": "uptime_monitor_states_pkey", + "columns": [ + "monitor_id" + ] + } + }, + { + "kind": "table", + "name": "uptime_monitors", + "primaryKey": { + "name": "uptime_monitors_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vcs_commits", + "primaryKey": { + "name": "vcs_commits_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vcs_installations", + "primaryKey": { + "name": "vcs_installations_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vcs_repositories", + "primaryKey": { + "name": "vcs_repositories_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vcs_repository_branches", + "primaryKey": { + "name": "vcs_repository_branches_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "column", + "table": "actors", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "type", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "user_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "agent_name", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "model", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "capabilities_json", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "created_by", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "last_active_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "user_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "kind", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "impact", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "summary", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "reason", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "details", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "related_to", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "agent_type", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "agent_name", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "agent_model", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "agent_version", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "source", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "max_runs_per_day", + "position": 2, + "type": "integer", + "notNull": true, + "default": "20", + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "max_passes_per_day", + "position": 3, + "type": "integer", + "notNull": true, + "default": "90", + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "updated_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "updated_by", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "incident_id", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "rule_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "destination_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "delivery_key", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "event_type", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "attempt_number", + "position": 7, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "status", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "scheduled_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "claimed_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "claim_expires_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "claimed_by", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "attempted_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "provider_message", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "provider_reference", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "response_code", + "position": 16, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "error_message", + "position": 17, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "silence_id", + "position": 18, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "payload_json", + "position": 19, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "created_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "updated_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "enabled", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "config_json", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "secret_ciphertext", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "secret_iv", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "secret_tag", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "last_tested_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "last_test_error", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "consecutive_failures", + "position": 11, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "last_failure_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "disabled_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "disabled_reason", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "created_by", + "position": 17, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "updated_by", + "position": 18, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "rule_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "incident_key", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "rule_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "group_key", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "signal_type", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "severity", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "peak_severity", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "status", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "comparator", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "threshold", + "position": 11, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "threshold_upper", + "position": 12, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "first_triggered_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_triggered_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "resolved_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_observed_value", + "position": 16, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_sample_count", + "position": 17, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_evaluated_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "dedupe_key", + "position": 19, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_delivered_event_type", + "position": 20, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_notified_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "hold_reason", + "position": 22, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "held_since", + "position": 23, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "error_issue_id", + "position": 24, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "silence_id", + "position": 25, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "silenced_at", + "position": 26, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "created_at", + "position": 27, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "updated_at", + "position": 28, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "destination_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "thread_key", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "linear_team_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "linear_issue_id", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "linear_identifier", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "linear_url", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "incident_id", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_claims", + "name": "rule_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_claims", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_claims", + "name": "last_scheduled_at", + "position": 2, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_claims", + "name": "consecutive_limit_failures", + "position": 3, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "rule_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "group_key", + "position": 2, + "type": "text", + "notNull": true, + "default": "'__total__'", + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "consecutive_breaches", + "position": 3, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "consecutive_healthy", + "position": 4, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "history_json", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_status", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_value", + "position": 7, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_sample_count", + "position": 8, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_evaluated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_error", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_error_kind", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "updated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "notes", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "notification_template_json", + "position": 4, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "enabled", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "severity", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "service_names_json", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "exclude_service_names_json", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "environments_json", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "tags_json", + "position": 10, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "signal_type", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "comparator", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "threshold", + "position": 13, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "threshold_upper", + "position": 14, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "thresholds_json", + "position": 15, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "window_minutes", + "position": 16, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "evaluation_interval_minutes", + "position": 17, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "slowed_interval_minutes", + "position": 18, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "slowed_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "minimum_sample_count", + "position": 20, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "consecutive_breaches_required", + "position": 21, + "type": "integer", + "notNull": true, + "default": "2", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "breaches_out_of", + "position": 22, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "consecutive_healthy_required", + "position": 23, + "type": "integer", + "notNull": true, + "default": "2", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "renotify_interval_minutes", + "position": 24, + "type": "integer", + "notNull": true, + "default": "30", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "apdex_threshold_ms", + "position": 25, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "query_builder_draft_json", + "position": 26, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "raw_query_sql", + "position": 27, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "group_by", + "position": 28, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "destination_ids_json", + "position": 29, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "query_spec_json", + "position": 30, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "reducer", + "position": 31, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "sample_count_strategy", + "position": 32, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "no_data_behavior", + "position": 33, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "last_scheduled_at", + "position": 34, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "created_at", + "position": 35, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "updated_at", + "position": 36, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "created_by", + "position": 37, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "updated_by", + "position": 38, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "reason", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "matchers_json", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "starts_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "ends_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "updated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "created_by", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "updated_by", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "sensitivity", + "position": 2, + "type": "text", + "notNull": true, + "default": "'normal'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "muted_signals_json", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "last_tick_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "updated_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "updated_by", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "detector_key", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "signal_type", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "service_name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "deployment_env", + "position": 4, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "fingerprint_hash", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "consecutive_breaches", + "position": 6, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "consecutive_healthy", + "position": 7, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_status", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_value", + "position": 9, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "baseline_median", + "position": 10, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_sample_count", + "position": 11, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_evaluated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "open_incident_id", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_resolved_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_incident_id", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "detector_key", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "signal_type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "service_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "deployment_env", + "position": 5, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "fingerprint_hash", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "error_issue_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "status", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "severity", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "opened_value", + "position": 10, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "baseline_median", + "position": 11, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "baseline_sigma", + "position": 12, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "threshold_value", + "position": 13, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_observed_value", + "position": 14, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_sample_count", + "position": 15, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "first_triggered_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_triggered_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "resolved_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "resolve_reason", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "triage_status", + "position": 20, + "type": "text", + "notNull": true, + "default": "'none'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "dedupe_key", + "position": 21, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "fingerprints_json", + "position": 22, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "reopen_count", + "position": 23, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_reopened_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "created_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "updated_at", + "position": 26, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "description", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "key_hash", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "key_prefix", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "revoked", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "revoked_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "last_used_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "expires_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "metadata_json", + "position": 10, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "scopes", + "position": 11, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "kind", + "position": 12, + "type": "text", + "notNull": true, + "default": "'standard'", + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "created_by", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "created_by_email", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "account_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "modules", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "granted_modules", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "template_version", + "position": 6, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "verified_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "last_checked_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "last_check_error", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "check_lease_until", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "metric_stream", + "position": 11, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "metric_stream_changed_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_secret_ciphertext", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_secret_iv", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_secret_tag", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_secret_hash", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_last_received_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_last_error", + "position": 18, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "created_by", + "position": 19, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "created_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "updated_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "plan_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "subscription_started_at", + "position": 3, + "type": "bigint", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "canceled_at", + "position": 4, + "type": "bigint", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "snapshot_json", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "rule_reason", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "model_read", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "posted_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "connector", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "external_user_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "user_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "display_name", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "connector", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "external_workspace_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "settings", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "credentials_ciphertext", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "credentials_iv", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "credentials_tag", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "device_code_hash", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "user_code_hash", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "device_name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_org_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_user_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_roles", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_user_email", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "api_key_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "token_ciphertext", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "token_iv", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "token_tag", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "denied_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "expires_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "account_id", + "position": 2, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "dataset", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "zone_id", + "position": 4, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "zone_name", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "enabled", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "watermark_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "backfill_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "settings_json", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "settings_fetched_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "quantiles_available", + "position": 11, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "discovered_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "live_scripts_json", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "grant_call_cap", + "position": 14, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "last_success_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "last_error", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "last_error_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "lease_until", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "created_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "updated_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "account_id", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "config_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_host", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_port", + "position": 6, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_scheme", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_database", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_user", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "deleted_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "created_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "updated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "zone_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "service_name", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "dataset", + "position": 6, + "type": "text", + "notNull": true, + "default": "'http_requests'", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_ciphertext", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_iv", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_tag", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_hash", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "enabled", + "position": 11, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "last_received_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "last_error", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_rotated_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "created_by", + "position": 17, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "updated_by", + "position": 18, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_zone_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_zone_settings", + "name": "account_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_zone_settings", + "name": "watch_new_zones", + "position": 2, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_zone_settings", + "name": "zone_overrides", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": "'{}'", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_zone_settings", + "name": "created_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_zone_settings", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_zone_settings", + "name": "updated_by", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "dashboard_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "widget_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "mode", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_hash", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_ciphertext", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_iv", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_tag", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_suffix", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "created_by", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "updated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "updated_by", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "revoked_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "dashboard_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "version_number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "snapshot_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "change_kind", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "change_summary", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "source_version_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "created_by", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "payload_json", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "created_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "created_by", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "updated_by", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "version", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "lockfile_path", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "ecosystem", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "package_name", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "advisory_id", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "aliases", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "severity", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "summary", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "versions", + "position": 10, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "fixed_in", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "relation", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "status", + "position": 13, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "first_seen_scan_id", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "last_seen_scan_id", + "position": 15, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "first_seen_sha", + "position": 16, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "last_seen_sha", + "position": 17, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "fixed_scan_id", + "position": 18, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "fixed_sha", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "status_reason", + "position": 20, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "status_changed_by", + "position": 21, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "status_changed_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "created_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "updated_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "scan_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "lockfile_path", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "ecosystem", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "package_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "version", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "relation", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "advisory_id", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "aliases", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "severity", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "summary", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "fixed_in", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "commit_sha", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "ref", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "trigger", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "default_branch_head", + "position": 6, + "type": "boolean", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "status", + "position": 7, + "type": "text", + "notNull": true, + "default": "'running'", + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "lockfiles", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "package_count", + "position": 9, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "vulnerable_count", + "position": 10, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "error", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "started_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "finished_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "user_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "installation_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "public_jwk", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "jkt", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "roles", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "scopes", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "attested", + "position": 8, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "attestation", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "last_used_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "expires_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "hard_expires_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "revoked_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_proof_replays", + "name": "key", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_proof_replays", + "name": "expires_at", + "position": 1, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "user_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "email", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "enabled", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "opted_out_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "day_of_week", + "position": 6, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "timezone", + "position": 7, + "type": "text", + "notNull": true, + "default": "'UTC'", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "send_hour", + "position": 8, + "type": "integer", + "notNull": true, + "default": "9", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "namespaces_json", + "position": 9, + "type": "text", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "environments_json", + "position": 10, + "type": "text", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "last_sent_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "last_attempted_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_enabled", + "position": 13, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_opted_out_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_last_sent_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_last_attempted_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "created_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "updated_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "fingerprint_hash", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "service_name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "exception_type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "exception_message", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "error_label", + "position": 5, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "top_frame", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "service_versions_json", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "occurrence_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "first_seen_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "last_seen_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "status", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "reason", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "first_triggered_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "last_triggered_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "resolved_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "occurrence_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "actor_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "type", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "from_state", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "to_state", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "payload_json", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": "'{}'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_fingerprints", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_fingerprints", + "name": "fingerprint_hash", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_fingerprints", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_fingerprints", + "name": "source", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_fingerprints", + "name": "origin_issue_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_fingerprints", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "provider", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "external_repo_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "repo_full_name", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "number", + "position": 6, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "url", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "title", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "author_login", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "state", + "position": 10, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "merged_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "merge_commit_sha", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "link_source", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "linked_by_actor_id", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "issue_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "last_observed_occurrence_at", + "position": 2, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "last_evaluated_at", + "position": 3, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "open_incident_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "pull_request_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "status", + "position": 4, + "type": "text", + "notNull": true, + "default": "'waiting'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "merged_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "verify_after", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "baseline_versions_json", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "baseline_occurrence_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "baseline_rate_per_hour", + "position": 9, + "type": "double precision", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "investigation_id", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "verdict", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "verdict_note", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "post_merge_occurrence_count", + "position": 13, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "attempt", + "position": 14, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "kind", + "position": 2, + "type": "text", + "notNull": true, + "default": "'error'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "source_ref_json", + "position": 3, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "fingerprint_hash", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "fingerprint_version", + "position": 5, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "service_name", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "exception_type", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "exception_message", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "error_label", + "position": 9, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "top_frame", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "workflow_state", + "position": 11, + "type": "text", + "notNull": true, + "default": "'triage'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "priority", + "position": 12, + "type": "integer", + "notNull": true, + "default": "3", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "severity", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "severity_source", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "assigned_actor_id", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "lease_holder_actor_id", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "lease_expires_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "claimed_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "notes", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "first_seen_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "last_seen_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "occurrence_count", + "position": 22, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "resolved_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "resolved_by_actor_id", + "position": 24, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "last_resolved_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "last_regressed_at", + "position": 26, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "regression_count", + "position": 27, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "seen_versions_json", + "position": 28, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "resolved_versions_json", + "position": 29, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "snooze_until", + "position": 30, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "archived_at", + "position": 31, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "merged_into_issue_id", + "position": 32, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "created_at", + "position": 33, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "updated_at", + "position": 34, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "destination_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "delivery_key", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "payload_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "status", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "attempt_count", + "position": 6, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "scheduled_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "claimed_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "claim_expires_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "claimed_by", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "attempted_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "error_message", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "updated_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "destination_ids_json", + "position": 2, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_first_seen", + "position": 3, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_regression", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_resolve", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_transition_in_review", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_transition_done", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_claim", + "position": 8, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "min_occurrence_count", + "position": 9, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "severity", + "position": 10, + "type": "text", + "notNull": true, + "default": "'warning'", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "updated_by", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "processed_through", + "position": 1, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "bootstrap_completed", + "position": 2, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "claim_token", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "claim_expires_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "scope_type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "scope_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "project_id", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "logs_enabled", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "metrics_enabled", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "secret_ciphertext", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "secret_iv", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "secret_tag", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "secret_hash", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_received_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_error", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "applied_logs_enabled", + "position": 14, + "type": "boolean", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "applied_metrics_enabled", + "position": 15, + "type": "boolean", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "setup_reported_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "metrics_watermark_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_metrics_received_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_metrics_error", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "metrics_lease_until", + "position": 20, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "resources_synced_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_resources_error", + "position": 22, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "created_by", + "position": 23, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "created_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "updated_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "connector_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "asset_type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "project_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "location", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "display_name", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "state", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "labels", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "details", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "resource_created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "resource_updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "last_seen_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "status", + "position": 2, + "type": "text", + "notNull": true, + "default": "'investigating'", + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "seeded_by", + "position": 3, + "type": "text", + "notNull": true, + "default": "'user'", + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "subject_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "snapshot_json", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "incident_kind", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "incident_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "issue_id", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "report_json", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "progress_json", + "position": 10, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "severity", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "confidence", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "model", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "input_tokens", + "position": 14, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "output_tokens", + "position": 15, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "error", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "started_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "autonomous_turns", + "position": 18, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "created_by", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "created_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "diagnosed_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "updated_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "rules_json", + "position": 2, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "updated_at", + "position": 3, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "updated_by", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "severity", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "source", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "reason", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "run_id", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "investigation_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "payload_json", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": "'{}'", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "delivery_results_json", + "position": 9, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "status", + "position": 10, + "type": "text", + "notNull": true, + "default": "'queued'", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "attempts", + "position": 11, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "dedupe_key", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "error", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "processed_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "device_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "incident_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "activity_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "push_token", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "ended_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "ended_reason", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "updated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "request_id_hash", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "client_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "client_name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "redirect_uri", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "state", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "resource", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "scopes", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "code_challenge", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "authorization_code_hash", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_org_id", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_user_id", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_roles", + "position": 11, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_user_email", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "denied_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "used_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "created_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "expires_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "client_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "client_name", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "redirect_uris", + "position": 2, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "client_uri", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "created_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "token_hash", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "family_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "client_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "resource", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "scopes", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "org_id", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "user_id", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "roles", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "user_email", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "access_key_id", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "replaced_by_id", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "revoked_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "expires_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "family_expires_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "user_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "platform", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "token", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "environment", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "bundle_id", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "app_version", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "live_activity_start_token", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "device_name", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "preferences", + "position": 10, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "disabled_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "disabled_reason", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "last_pushed_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "last_seen_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "state", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "initiated_by_user_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "redirect_uri", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "return_to", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "code_verifier", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "github_installation_ids", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "expires_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "provider", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "external_user_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "external_user_email", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "external_account_name", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "granted_accounts_json", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "connected_by_user_id", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "scope", + "position": 9, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "access_token_ciphertext", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "access_token_iv", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "access_token_tag", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "refresh_token_ciphertext", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "refresh_token_iv", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "refresh_token_tag", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "expires_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "revoked_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "created_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "updated_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_billing_links", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_billing_links", + "name": "billing_org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_billing_links", + "name": "linked_by", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_billing_links", + "name": "created_at", + "position": 3, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "workflow_instance_id", + "position": 1, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "status", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "phase", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "current_migration", + "position": 4, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "steps_total", + "position": 5, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "steps_done", + "position": 6, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "applied_versions", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "skipped", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "error_message", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "started_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "finished_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "created_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "updated_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_url", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_user", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_password_ciphertext", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_password_iv", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_password_tag", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_database", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "sync_status", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "last_sync_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "last_sync_error", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "schema_version", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "created_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "updated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "created_by", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "updated_by", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "source_context", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "source_key", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "target_key", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "operation", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "enabled", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "updated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "public_key", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "public_key_hash", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_key_ciphertext", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_key_iv", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_key_tag", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_key_hash", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "public_rotated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_rotated_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "created_by", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "updated_by", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_settings", + "name": "hash_salt", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_settings", + "name": "created_at", + "position": 2, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "rule_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "bucket", + "position": 2, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "signal", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "matched", + "position": 4, + "type": "bigint", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "dropped", + "position": 5, + "type": "bigint", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "dropped_bytes", + "position": 6, + "type": "bigint", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "redacted_values", + "position": 7, + "type": "bigint", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "enabled", + "position": 3, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "position", + "position": 4, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "spec_json", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "updated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "trace_sample_ratio", + "position": 1, + "type": "double precision", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "always_keep_error_spans", + "position": 2, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "always_keep_slow_spans_ms", + "position": 3, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "created_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "user_id", + "position": 1, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "email", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "role", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "demo_data_requested", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "onboarding_completed_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "checklist_dismissed_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "first_data_received_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "welcome_email_sent_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "connect_nudge_email_sent_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "stalled_email_sent_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "activation_email_sent_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "reward_claimed_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "reward_reserved_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "updated_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "key_ciphertext", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "key_iv", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "key_tag", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "key_hint", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "updated_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "updated_by", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "number", + "position": 2, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "recommendation_key", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "kind", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "source_key", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "canonical_key", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "status", + "position": 7, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "usage_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "opened_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "resolved_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "slack_channel_id", + "position": 1, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "slack_channel_name", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "reservation_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "reserved_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "created_by_user_id", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "updated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "ps_organization", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "connected_by_user_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "scrape_target_id", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "webhook_secret_ciphertext", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "webhook_secret_iv", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "webhook_secret_tag", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "detected_permissions_json", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "last_inventory_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "last_inventory_error", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "created_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "updated_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "database_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "kind", + "position": 4, + "type": "text", + "notNull": true, + "default": "'mysql'", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "state", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "region", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "plan", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "branches_json", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "webhook_id", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "webhook_secret_ciphertext", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "webhook_secret_iv", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "webhook_secret_tag", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "webhook_error", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "deleted_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "database_id", + "position": 2, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "database_name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "branch_name", + "position": 4, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "category", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "event_type", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "state", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "external_id", + "position": 8, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "title", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "source", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "actor_login", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "url", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "payload_json", + "position": 13, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "occurred_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_issue_receipts", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_issue_receipts", + "name": "event_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_issue_receipts", + "name": "processed_at", + "position": 2, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "dataset", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "database_id", + "position": 3, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "enabled", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "watermark_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "last_success_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "last_error", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "last_error_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "lease_until", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "reply_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "seq", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "path", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "old_text", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "new_text", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "finding_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "model", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "embedding", + "position": 4, + "type": "real[]", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "review_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "handle", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "path", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "line", + "position": 7, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "category", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "severity", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "title", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "status", + "position": 11, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "comment_id", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "resolved_sha", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "reactions_up", + "position": 14, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "reactions_down", + "position": 15, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "created_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "updated_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "key", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "kind", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "title", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "source", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "path", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "status", + "position": 9, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "done_by", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "done_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "reminded_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "first_review_id", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "last_review_id", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "comment_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "surface", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "thread_root_id", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "author_login", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "command", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "head_sha", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "status", + "position": 10, + "type": "text", + "notNull": true, + "default": "'queued'", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "session_id", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "reply_url", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "commit_sha", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "error", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "model", + "position": 1, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "defaults", + "position": 2, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "updated_at", + "position": 3, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "updated_by", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "head_sha", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "base_sha", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "url", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "title", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "author_login", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "status", + "position": 9, + "type": "text", + "notNull": true, + "default": "'queued'", + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "skip_reason", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "session_id", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "comment_attempt", + "position": 12, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "report_json", + "position": 13, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "score", + "position": 14, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "check_run_url", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "comment_url", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "review_url", + "position": 17, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "publish_error", + "position": 18, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "error", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "model", + "position": 20, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "input_tokens", + "position": 21, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "output_tokens", + "position": 22, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "started_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "finished_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "merged_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "telemetry_json", + "position": 26, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "merge_commit_sha", + "position": 27, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "post_merge_status", + "position": 28, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "post_merge_after", + "position": 29, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "post_merge_json", + "position": 30, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "created_at", + "position": 31, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "updated_at", + "position": 32, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "token_ciphertext", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "token_iv", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "token_tag", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "workspace_names", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "connected_by_user_id", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "auth_failed_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "discovered_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "lease_until", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "last_success_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "last_error", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "last_error_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "updated_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "connection_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "project_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "project_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "environment_id", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "environment_name", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "services_json", + "position": 7, + "type": "text", + "notNull": true, + "default": "'{}'", + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "enabled", + "position": 8, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "watermark_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "last_success_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "last_error", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "last_error_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "updated_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "id", + "position": 0, + "type": "integer", + "notNull": true, + "default": null, + "identity": "by default" + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "target_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "sub_target_key", + "position": 3, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "checked_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "error", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "duration_ms", + "position": 6, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "samples_scraped", + "position": 7, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "samples_post_relabel", + "position": 8, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "service_name", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "url", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "target_type", + "position": 6, + "type": "text", + "notNull": true, + "default": "'prometheus'", + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "discovery_config_json", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "scrape_interval_seconds", + "position": 8, + "type": "integer", + "notNull": true, + "default": "15", + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "labels_json", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "auth_type", + "position": 10, + "type": "text", + "notNull": true, + "default": "'none'", + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "managed_by", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "auth_credentials_ciphertext", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "auth_credentials_iv", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "auth_credentials_tag", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "enabled", + "position": 15, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "last_scrape_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "last_scrape_error", + "position": 17, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "created_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "updated_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "run_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "at", + "position": 3, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "kind", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "role", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "agent_id", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "outcome", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "detail", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "input_tokens", + "position": 9, + "type": "bigint", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "output_tokens", + "position": 10, + "type": "bigint", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "target_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "fingerprint", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "verdict", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "overall_severity", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "estimated_severity", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "record", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "status", + "position": 8, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "first_seen_run_id", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "last_seen_run_id", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "first_seen_ref", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "last_seen_ref", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "fixed_run_id", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "status_reason", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "status_changed_by", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "status_changed_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "created_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "updated_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_reservations", + "name": "run_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_reservations", + "name": "agent_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_reservations", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_reservations", + "name": "cost_usd", + "position": 3, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_reservations", + "name": "sandbox_fee_usd", + "position": 4, + "type": "double precision", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_reservations", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_settings", + "name": "monthly_spend_limit_usd", + "position": 1, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_settings", + "name": "discovery_model", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_settings", + "name": "validation_model", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_settings", + "name": "updated_by", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_settings", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_run_findings", + "name": "run_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_run_findings", + "name": "fingerprint", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_run_findings", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_run_findings", + "name": "verdict", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_run_findings", + "name": "overall_severity", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_run_findings", + "name": "estimated_severity", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_run_findings", + "name": "record", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "target_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "kind", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "source_ref", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "profile", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "scope_paths", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "budget", + "position": 7, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "metadata", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "status", + "position": 9, + "type": "text", + "notNull": true, + "default": "'in_progress'", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "incomplete_reason", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "architecture_md", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "reports", + "position": 12, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "execution_profile", + "position": 13, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "invocations_spent", + "position": 14, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "input_tokens", + "position": 15, + "type": "bigint", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "output_tokens", + "position": 16, + "type": "bigint", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "cost_usd", + "position": 17, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "sandbox_usd", + "position": 18, + "type": "double precision", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "discovery_model", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "validation_model", + "position": 20, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "workflow_instance_id", + "position": 21, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "started_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "finished_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "error", + "position": 24, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "repository", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "vcs_repository_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "branch", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "enabled", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "profile", + "position": 6, + "type": "text", + "notNull": true, + "default": "'deep'", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "scope_paths", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "slice_interval_hours", + "position": 8, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "revalidate_on_push", + "position": 9, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "budget_per_run", + "position": 10, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "monthly_invocation_budget", + "position": 11, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "discovery_model", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "validation_model", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "created_by", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_units", + "name": "run_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_units", + "name": "coverage_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_units", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_units", + "name": "status", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_units", + "name": "wave", + "position": 4, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_units", + "name": "agent_id", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_units", + "name": "unit", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "description", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "platform", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "kind", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "key_hash", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "key_prefix", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "allowed_signals", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "rate_limit_rps", + "position": 9, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "resource_attributes", + "position": 10, + "type": "jsonb", + "notNull": true, + "default": "'[]'::jsonb", + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "expires_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "paused_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "paused_until", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_used_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_rejected_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_rejection", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_poll_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_poll_ok_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_poll_error", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "disconnected_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "poll_resources", + "position": 21, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "rolled_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "created_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "updated_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "created_by", + "position": 25, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "updated_by", + "position": 26, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_cleanups", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_cleanups", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_cleanups", + "name": "page_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_cleanups", + "name": "slug", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_cleanups", + "name": "delete_snapshot", + "position": 4, + "type": "boolean", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_cleanups", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "page_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "description", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "group_name", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "position", + "position": 6, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "monitor_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "manual_status", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "show_uptime", + "position": 9, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_components", + "name": "incident_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_components", + "name": "component_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_components", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "incident_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "status", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "body", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "source", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "created_by", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "overlay_entry_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "page_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "kind", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "title", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "impact", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "status", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "published", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "alert_incident_id", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "scheduled_start", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "scheduled_end", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "started_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "resolved_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "created_by", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "updated_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "slug", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "description", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "website_url", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "logo_url", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "privacy_url", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "terms_url", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "theme", + "position": 9, + "type": "jsonb", + "notNull": true, + "default": "'{\"preset\":\"default\",\"appearance\":\"auto\",\"accent\":null,\"background\":null,\"text\":null,\"card\":null,\"border\":null,\"radius\":\"md\",\"font\":\"sans\"}'", + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "show_powered_by", + "position": 10, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "auto_incidents", + "position": 11, + "type": "text", + "notNull": true, + "default": "'off'", + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "break_glass_token_sha256", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "break_glass_token_suffix", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "draft", + "position": 14, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "revision", + "position": 15, + "type": "bigint", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "published_revision", + "position": 16, + "type": "bigint", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "published_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "last_publish_error", + "position": 18, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "published_hash", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "publish_attempted_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "created_by", + "position": 21, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "created_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "updated_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "supabase_organization_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "supabase_organization_slug", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "supabase_organization_name", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "connected_by_user_id", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "discovery_lease_until", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "last_discovery_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "last_discovery_error", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "project_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "advisors_json", + "position": 2, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "top_queries_json", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "advisors_checked_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "queries_checked_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "advisors_error", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "queries_error", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "lease_until", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "connection_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "project_ref", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "region", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "status", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "metrics_enabled", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "scrape_target_id", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "log_drain_enabled", + "position": 9, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "log_drain_id", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "log_drain_source_id", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "log_drain_error", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "log_drains_json", + "position": 13, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "log_drains_checked_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "logs_enabled", + "position": 15, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "logs_watermark_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "logs_boundary_ids", + "position": 17, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "logs_lease_until", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "logs_last_polled_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "logs_error", + "position": 20, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "last_seen_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "removed_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "created_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "updated_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_daily", + "name": "monitor_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_daily", + "name": "day", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_daily", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_daily", + "name": "checks", + "position": 3, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_daily", + "name": "failures", + "position": 4, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_daily", + "name": "down_checks", + "position": 5, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_daily", + "name": "last_check_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "monitor_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "status", + "position": 2, + "type": "text", + "notNull": true, + "default": "'pending'", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "consecutive_failures", + "position": 3, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "consecutive_successes", + "position": 4, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "status_changed_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "last_check_json", + "position": 6, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "recent_json", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "last_ping_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "updated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "kind", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "config_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "interval_seconds", + "position": 5, + "type": "integer", + "notNull": true, + "default": "60", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "failures_to_down", + "position": 6, + "type": "integer", + "notNull": true, + "default": "2", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "service_name", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "alert_rule_id", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "heartbeat_token", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "enabled", + "position": 10, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "created_by", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "created_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "updated_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "repository_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "sha", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "message", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "author_name", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "author_email", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "author_login", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "author_avatar_url", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "authored_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "committed_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "html_url", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "provider", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "external_installation_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "account_login", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "account_type", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "external_account_id", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "account_avatar_url", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "repository_selection", + "position": 9, + "type": "text", + "notNull": true, + "default": "'all'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "status", + "position": 10, + "type": "text", + "notNull": true, + "default": "'active'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "suspended_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "ci_traces_enabled", + "position": 12, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "repository_discovery", + "position": 13, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "installed_by_user_id", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "installation_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "external_repo_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "owner", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "name", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "full_name", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "default_branch", + "position": 8, + "type": "text", + "notNull": true, + "default": "'main'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "tracked_branch", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "html_url", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "is_private", + "position": 11, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "is_archived", + "position": 12, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "status", + "position": 13, + "type": "text", + "notNull": true, + "default": "'active'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "sync_status", + "position": 14, + "type": "text", + "notNull": true, + "default": "'pending'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "last_synced_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "last_sync_error", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "pr_review_enabled", + "position": 17, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "pr_review_config", + "position": 18, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "created_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "updated_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "repository_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "is_default", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "head_sha", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "updated_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "index", + "table": "actors", + "name": "actors_org_agent_name_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"agent_name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "actors", + "name": "actors_org_type_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"type\"" + ], + "where": null + }, + { + "kind": "index", + "table": "actors", + "name": "actors_org_user_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "agent_feedback", + "name": "agent_feedback_kind_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"kind\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "agent_feedback", + "name": "agent_feedback_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_claim_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"claim_expires_at\"", + "\"scheduled_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_delivery_attempt_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"delivery_key\"", + "\"attempt_number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"scheduled_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_org_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_destinations", + "name": "alert_destinations_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_destinations", + "name": "alert_destinations_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_destinations", + "name": "alert_destinations_org_name_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_incident_key_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"incident_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_open_group_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"rule_id\"", + "\"group_key\"" + ], + "where": "\"status\" = 'open'" + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"error_issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_org_rule_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"rule_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_org_status_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_linear_issues", + "name": "alert_linear_issues_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_linear_issues", + "name": "alert_linear_issues_thread_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"destination_id\"", + "\"thread_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rule_claims", + "name": "alert_rule_claims_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rule_states", + "name": "alert_rule_states_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rules", + "name": "alert_rules_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rules", + "name": "alert_rules_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rules", + "name": "alert_rules_org_name_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_silences", + "name": "alert_silences_org_ends_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"ends_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_detector_states", + "name": "anomaly_detector_states_evaluated_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"last_evaluated_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_detector_states", + "name": "anomaly_detector_states_open_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"open_incident_id\"" + ], + "where": "\"open_incident_id\" is not null" + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_open_detector_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"detector_key\"" + ], + "where": "\"status\" = 'open'" + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_detector_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"detector_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"error_issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_status_triggered_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"", + "\"last_triggered_at\"", + "\"id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_triggered_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"last_triggered_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "api_keys", + "name": "api_keys_key_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"key_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "api_keys", + "name": "api_keys_org_id_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "aws_connections", + "name": "aws_connections_org_account_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"account_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "aws_connections", + "name": "aws_connections_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "aws_connections", + "name": "aws_connections_stream_secret_hash_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"stream_secret_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cancellation_reviews", + "name": "cancellation_reviews_subscription_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"plan_id\"", + "\"subscription_started_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_identities", + "name": "chat_identities_org_connector_external_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"connector\"", + "\"external_user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_identities", + "name": "chat_identities_org_connector_user_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"connector\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_identities", + "name": "chat_identities_org_user_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_workspaces", + "name": "chat_workspaces_connector_external_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"connector\"", + "\"external_workspace_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_workspaces", + "name": "chat_workspaces_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cli_device_authorizations", + "name": "cli_device_authorizations_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cli_device_authorizations", + "name": "cli_device_authorizations_user_code_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"user_code_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_analytics_state", + "name": "cf_analytics_state_org_account_dataset_zone_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"account_id\"", + "\"dataset\"", + "\"zone_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_analytics_state", + "name": "cf_analytics_state_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_hyperdrive_configs", + "name": "cloudflare_hyperdrive_configs_org_config_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"config_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_hyperdrive_configs", + "name": "cloudflare_hyperdrive_configs_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_secret_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"secret_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_id_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_live_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"", + "coalesce(widget_id, '')" + ], + "where": "revoked_at is null" + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_org_dashboard_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_token_hash_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"token_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_versions", + "name": "dashboard_versions_org_dashboard_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"", + "\"version_number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_versions", + "name": "dashboard_versions_org_dashboard_version_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"", + "\"version_number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboards", + "name": "dashboards_org_name_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboards", + "name": "dashboards_org_updated_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"updated_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dependency_findings", + "name": "dependency_findings_key_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"lockfile_path\"", + "\"ecosystem\"", + "\"package_name\"", + "\"advisory_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dependency_findings", + "name": "dependency_findings_org_status_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dependency_findings", + "name": "dependency_findings_org_updated_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"updated_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dependency_scan_matches", + "name": "dependency_scan_matches_org_scan_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"scan_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dependency_scans", + "name": "dependency_scans_org_started_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"started_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dependency_scans", + "name": "dependency_scans_repo_commit_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"commit_sha\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dependency_scans", + "name": "dependency_scans_repo_started_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"started_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "device_credentials", + "name": "device_credentials_jkt_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"jkt\"" + ], + "where": null + }, + { + "kind": "index", + "table": "device_credentials", + "name": "device_credentials_live_installation_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"installation_id\"" + ], + "where": "\"revoked_at\" IS NULL" + }, + { + "kind": "index", + "table": "device_credentials", + "name": "device_credentials_user_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "device_proof_replays", + "name": "device_proof_replays_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "digest_subscriptions", + "name": "digest_subscriptions_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "digest_subscriptions", + "name": "digest_subscriptions_org_user_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_fingerprint_candidates", + "name": "error_fingerprint_candidates_last_seen_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"last_seen_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_incidents", + "name": "error_incidents_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_incidents", + "name": "error_incidents_org_status_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"", + "\"last_triggered_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_events", + "name": "error_issue_events_actor_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"actor_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_events", + "name": "error_issue_events_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_events", + "name": "error_issue_events_type_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"type\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_fingerprints", + "name": "error_issue_fingerprints_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_pull_requests", + "name": "error_issue_pull_requests_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_pull_requests", + "name": "error_issue_pull_requests_issue_pr_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"", + "\"provider\"", + "\"repo_full_name\"", + "\"number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_pull_requests", + "name": "error_issue_pull_requests_repo_number_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"provider\"", + "\"repo_full_name\"", + "\"number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_verifications", + "name": "error_issue_verifications_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"verify_after\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_verifications", + "name": "error_issue_verifications_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_verifications", + "name": "error_issue_verifications_open_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": "\"status\" in ('waiting', 'running')" + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_lease_expiry_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"lease_expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_archived_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"archived_at\"" + ], + "where": "\"archived_at\" is not null" + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_assignee_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"assigned_actor_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_fp_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"fingerprint_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_fp_version_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"fingerprint_version\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_live_seen_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"last_seen_at\"", + "\"id\"" + ], + "where": "\"archived_at\" is null" + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_merged_into_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"merged_into_issue_id\"" + ], + "where": "\"merged_into_issue_id\" is not null" + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_severity_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"severity\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_workflow_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"workflow_state\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_notification_deliveries", + "name": "error_notification_deliveries_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"scheduled_at\"", + "\"claim_expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_notification_deliveries", + "name": "error_notification_deliveries_key_destination_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"delivery_key\"", + "\"destination_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_notification_deliveries", + "name": "error_notification_deliveries_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_tick_states", + "name": "error_tick_states_claim_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"claim_expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "gcp_connectors", + "name": "gcp_connectors_org_scope_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"scope_type\"", + "\"scope_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "gcp_connectors", + "name": "gcp_connectors_secret_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"secret_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "gcp_connectors", + "name": "gcp_connectors_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "investigations", + "name": "investigations_incident_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_kind\"", + "\"incident_id\"" + ], + "where": "\"incident_id\" is not null" + }, + { + "kind": "index", + "table": "investigations", + "name": "investigations_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "investigations", + "name": "investigations_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "investigations", + "name": "investigations_org_status_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"" + ], + "where": null + }, + { + "kind": "index", + "table": "issue_escalations", + "name": "issue_escalations_dedupe_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"dedupe_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "issue_escalations", + "name": "issue_escalations_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "issue_escalations", + "name": "issue_escalations_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "live_activities", + "name": "live_activities_device_incident_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"device_id\"", + "\"incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "live_activities", + "name": "live_activities_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_authorizations", + "name": "mcp_oauth_authorizations_code_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"authorization_code_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_authorizations", + "name": "mcp_oauth_authorizations_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_refresh_tokens", + "name": "mcp_oauth_refresh_tokens_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_refresh_tokens", + "name": "mcp_oauth_refresh_tokens_family_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"family_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_refresh_tokens", + "name": "mcp_oauth_refresh_tokens_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"token_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mobile_devices", + "name": "mobile_devices_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mobile_devices", + "name": "mobile_devices_org_platform_token_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"platform\"", + "\"token\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mobile_devices", + "name": "mobile_devices_user_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oauth_auth_states", + "name": "oauth_auth_states_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oauth_connections", + "name": "oauth_connections_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oauth_connections", + "name": "oauth_connections_org_provider_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"provider\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oauth_connections", + "name": "oauth_connections_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_billing_links", + "name": "org_billing_links_billing_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"billing_org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_attribute_mappings", + "name": "org_ingest_attribute_mappings_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_keys", + "name": "org_ingest_keys_private_key_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"private_key_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_keys", + "name": "org_ingest_keys_public_key_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"public_key_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_keys", + "name": "org_ingest_keys_public_key_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"public_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_rule_stats", + "name": "org_ingest_rule_stats_org_bucket_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"bucket\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_rules", + "name": "org_ingest_rules_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_recommendation_issues", + "name": "org_recommendation_issues_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_recommendation_issues", + "name": "org_recommendation_issues_org_key_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"recommendation_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_connections", + "name": "planetscale_connections_org_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_connections", + "name": "planetscale_connections_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_databases", + "name": "planetscale_databases_org_db_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"database_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_databases", + "name": "planetscale_databases_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_events", + "name": "planetscale_events_dedupe_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"database_name\"", + "\"event_type\"", + "\"external_id\"", + "\"occurred_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_events", + "name": "planetscale_events_org_db_time_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"database_name\"", + "\"occurred_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_events", + "name": "planetscale_events_org_time_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"occurred_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_issue_receipts", + "name": "planetscale_issue_receipts_processed_at_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"processed_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_poll_state", + "name": "planetscale_poll_state_org_dataset_db_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dataset\"", + "\"database_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_poll_state", + "name": "planetscale_poll_state_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_edits", + "name": "pr_review_edits_reply_seq_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"reply_id\"", + "\"seq\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_finding_embeddings", + "name": "pr_review_finding_embeddings_org_model_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"model\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_findings", + "name": "pr_review_findings_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_findings", + "name": "pr_review_findings_pr_handle_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"number\"", + "\"handle\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_merge_steps", + "name": "pr_review_merge_steps_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_merge_steps", + "name": "pr_review_merge_steps_pr_key_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"number\"", + "\"key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_replies", + "name": "pr_review_replies_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_replies", + "name": "pr_review_replies_repo_comment_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"comment_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_reviews", + "name": "pr_reviews_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_reviews", + "name": "pr_reviews_post_merge_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"post_merge_after\"" + ], + "where": "\"post_merge_status\" = 'waiting'" + }, + { + "kind": "index", + "table": "pr_reviews", + "name": "pr_reviews_repo_number_head_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"number\"", + "\"head_sha\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_reviews", + "name": "pr_reviews_repo_number_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "railway_connections", + "name": "railway_connections_org_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "railway_connections", + "name": "railway_connections_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "railway_environments", + "name": "railway_environments_connection_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"connection_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "railway_environments", + "name": "railway_environments_org_env_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"environment_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "scrape_target_checks", + "name": "scrape_target_checks_target_checked_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"target_id\"", + "\"checked_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "scrape_targets", + "name": "scrape_targets_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "scrape_targets", + "name": "scrape_targets_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "scrape_targets", + "name": "scrape_targets_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_events", + "name": "security_audit_events_run_at_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"run_id\"", + "\"at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_findings", + "name": "security_audit_findings_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_findings", + "name": "security_audit_findings_target_fingerprint_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"target_id\"", + "\"fingerprint\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_org_reservations", + "name": "security_audit_org_reservations_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_runs", + "name": "security_audit_runs_one_running_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"target_id\"" + ], + "where": "\"finished_at\" IS NULL" + }, + { + "kind": "index", + "table": "security_audit_runs", + "name": "security_audit_runs_org_started_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"started_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_runs", + "name": "security_audit_runs_target_started_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"target_id\"", + "\"started_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_targets", + "name": "security_audit_targets_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_targets", + "name": "security_audit_targets_org_repo_branch_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"repository\"", + "\"branch\"" + ], + "where": null + }, + { + "kind": "index", + "table": "sources", + "name": "sources_key_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"key_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "sources", + "name": "sources_org_default_kind_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": "\"kind\" = 'default'" + }, + { + "kind": "index", + "table": "sources", + "name": "sources_org_id_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_cleanups", + "name": "status_page_cleanups_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_components", + "name": "status_page_components_monitor_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"monitor_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_components", + "name": "status_page_components_page_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"page_id\"", + "\"position\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_incident_components", + "name": "status_page_incident_components_component_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"component_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_incident_updates", + "name": "status_page_incident_updates_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"incident_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_incident_updates", + "name": "status_page_incident_updates_overlay_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"overlay_entry_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_incidents", + "name": "status_page_incidents_alert_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"alert_incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_incidents", + "name": "status_page_incidents_page_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"page_id\"", + "\"started_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_pages", + "name": "status_pages_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_pages", + "name": "status_pages_slug_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"slug\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_pages", + "name": "status_pages_unpublished_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"revision\"", + "\"published_revision\"" + ], + "where": "revision > published_revision" + }, + { + "kind": "index", + "table": "supabase_connections", + "name": "supabase_connections_org_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "supabase_connections", + "name": "supabase_connections_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "supabase_project_insights", + "name": "supabase_project_insights_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "supabase_projects", + "name": "supabase_projects_connection_ref_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"connection_id\"", + "\"project_ref\"" + ], + "where": null + }, + { + "kind": "index", + "table": "supabase_projects", + "name": "supabase_projects_log_drain_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"log_drain_source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "supabase_projects", + "name": "supabase_projects_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "uptime_monitor_daily", + "name": "uptime_monitor_daily_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "uptime_monitor_states", + "name": "uptime_monitor_states_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "uptime_monitors", + "name": "uptime_monitors_enabled_kind_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"enabled\"", + "\"kind\"" + ], + "where": null + }, + { + "kind": "index", + "table": "uptime_monitors", + "name": "uptime_monitors_heartbeat_token_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"heartbeat_token\"" + ], + "where": null + }, + { + "kind": "index", + "table": "uptime_monitors", + "name": "uptime_monitors_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_commits", + "name": "vcs_commits_org_sha_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"sha\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_commits", + "name": "vcs_commits_repo_sha_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"sha\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_installations", + "name": "vcs_installations_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_installations", + "name": "vcs_installations_provider_external_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"provider\"", + "\"external_installation_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_installations", + "name": "vcs_installations_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repositories", + "name": "vcs_repositories_installation_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"installation_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repositories", + "name": "vcs_repositories_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repositories", + "name": "vcs_repositories_org_repo_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"provider\"", + "\"external_repo_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repository_branches", + "name": "vcs_repository_branches_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repository_branches", + "name": "vcs_repository_branches_repo_name_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "foreign_key", + "table": "alert_linear_issues", + "name": "alert_linear_issues_destination_id_alert_destinations_id_fk", + "columns": [ + "destination_id" + ], + "foreignTable": "alert_destinations", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "aws_connections", + "name": "aws_connections_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "SET NULL", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "dashboard_shares", + "name": "dashboard_shares_dashboard_fk", + "columns": [ + "org_id", + "dashboard_id" + ], + "foreignTable": "dashboards", + "foreignColumns": [ + "org_id", + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "gcp_connectors", + "name": "gcp_connectors_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "gcp_resources", + "name": "gcp_resources_connector_id_gcp_connectors_id_fk", + "columns": [ + "connector_id" + ], + "foreignTable": "gcp_connectors", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "oauth_connections", + "name": "oauth_connections_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "planetscale_connections", + "name": "planetscale_connections_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "railway_connections", + "name": "railway_connections_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "scrape_target_checks", + "name": "scrape_target_checks_target_id_scrape_targets_id_fk", + "columns": [ + "target_id" + ], + "foreignTable": "scrape_targets", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "scrape_targets", + "name": "scrape_targets_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "status_page_components", + "name": "status_page_components_monitor_id_uptime_monitors_id_fk", + "columns": [ + "monitor_id" + ], + "foreignTable": "uptime_monitors", + "foreignColumns": [ + "id" + ], + "onDelete": "SET NULL", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "status_page_components", + "name": "status_page_components_page_id_status_pages_id_fk", + "columns": [ + "page_id" + ], + "foreignTable": "status_pages", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "status_page_incident_components", + "name": "status_page_incident_components_Bnqlt6wwKJty_fk", + "columns": [ + "component_id" + ], + "foreignTable": "status_page_components", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "status_page_incident_components", + "name": "status_page_incident_components_tBn6mEz4FenY_fk", + "columns": [ + "incident_id" + ], + "foreignTable": "status_page_incidents", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "status_page_incident_updates", + "name": "status_page_incident_updates_3LDDUAhznHPM_fk", + "columns": [ + "incident_id" + ], + "foreignTable": "status_page_incidents", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "status_page_incidents", + "name": "status_page_incidents_page_id_status_pages_id_fk", + "columns": [ + "page_id" + ], + "foreignTable": "status_pages", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "supabase_connections", + "name": "supabase_connections_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "supabase_project_insights", + "name": "supabase_project_insights_project_id_supabase_projects_id_fk", + "columns": [ + "project_id" + ], + "foreignTable": "supabase_projects", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "supabase_projects", + "name": "supabase_projects_connection_id_supabase_connections_id_fk", + "columns": [ + "connection_id" + ], + "foreignTable": "supabase_connections", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "supabase_projects", + "name": "supabase_projects_log_drain_source_id_sources_id_fk", + "columns": [ + "log_drain_source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "SET NULL", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "uptime_monitor_daily", + "name": "uptime_monitor_daily_monitor_id_uptime_monitors_id_fk", + "columns": [ + "monitor_id" + ], + "foreignTable": "uptime_monitors", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "uptime_monitor_states", + "name": "uptime_monitor_states_monitor_id_uptime_monitors_id_fk", + "columns": [ + "monitor_id" + ], + "foreignTable": "uptime_monitors", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "vcs_installations", + "name": "vcs_installations_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + } + ] +} diff --git a/packages/db/src/tables/alerts.ts b/packages/db/src/tables/alerts.ts index f1022d93e2..4475198ad3 100644 --- a/packages/db/src/tables/alerts.ts +++ b/packages/db/src/tables/alerts.ts @@ -4,6 +4,7 @@ import { AlertDestinationId, AlertIncidentId, AlertRuleId, + AlertSilenceId, ErrorIssueId, OrgId, } from "@maple/domain/primitives" @@ -215,6 +216,13 @@ const alertIncidentsColumns = { // Issue-hub link: the error_issues row (kind="alert") this incident // feeds, mirroring anomalyIncidents.errorIssueId. errorIssueId: PG.column(PG.nullable(PG.brand(PG.text, ErrorIssueId)), { name: "error_issue_id" }), + /** The silence that last held back one of this incident's notifications. */ + silenceId: PG.column(PG.nullable(PG.brand(PG.text, AlertSilenceId)), { name: "silence_id" }), + /** + * Set when a silence held back a notification the incident still owes. The first + * check after every matching silence ends sends it, then clears this. + */ + silencedAt: PG.column(PG.nullable(PG.timestamptzMillis), { name: "silenced_at" }), createdAt: PG.column(PG.timestamptzMillis, { name: "created_at" }), updatedAt: PG.column(PG.timestamptzMillis, { name: "updated_at" }), } @@ -265,6 +273,8 @@ export const AlertDeliveryEvents = PG.table("alert_delivery_events", { providerReference: PG.column(PG.nullable(PG.text), { name: "provider_reference" }), responseCode: PG.column(PG.nullable(PG.int4), { name: "response_code" }), errorMessage: PG.column(PG.nullable(PG.text), { name: "error_message" }), + /** Set on `status = 'silenced'` rows: the silence that held this delivery back. */ + silenceId: PG.column(PG.nullable(PG.brand(PG.text, AlertSilenceId)), { name: "silence_id" }), payloadJson: PG.column(PG.jsonb(), { name: "payload_json" }), createdAt: PG.column(PG.timestamptzMillis, { name: "created_at" }), updatedAt: PG.column(PG.timestamptzMillis, { name: "updated_at" }), @@ -280,6 +290,36 @@ export const AlertDeliveryEvents = PG.table("alert_delivery_events", { tenantColumn: "orgId", }) +const StoredSilenceMatchers = Schema.Struct({ + ruleIds: Schema.optionalKey(Schema.Array(Schema.String)), + tags: Schema.optionalKey(Schema.Array(Schema.String)), + services: Schema.optionalKey(Schema.Array(Schema.String)), + environments: Schema.optionalKey(Schema.Array(Schema.String)), +}) + +/** + * A window during which matching rules still evaluate and open incidents, but send + * no notifications. Ending one early moves `endsAt` to now; a started silence is + * never deleted, so incident and check history can still name it. + */ +export const AlertSilences = PG.table("alert_silences", { + columns: { + id: PG.brand(PG.text, AlertSilenceId), + orgId: PG.column(PG.brand(PG.text, OrgId), { name: "org_id" }), + reason: PG.text, + matchersJson: PG.column(PG.jsonb(StoredSilenceMatchers), { name: "matchers_json" }), + startsAt: PG.column(PG.timestamptzMillis, { name: "starts_at" }), + endsAt: PG.column(PG.timestamptzMillis, { name: "ends_at" }), + createdAt: PG.column(PG.timestamptzMillis, { name: "created_at" }), + updatedAt: PG.column(PG.timestamptzMillis, { name: "updated_at" }), + createdBy: PG.column(PG.text, { name: "created_by" }), + updatedBy: PG.column(PG.text, { name: "updated_by" }), + }, + primaryKey: ["id"], + indexes: [PG.index("alert_silences_org_ends_idx", ["orgId", "endsAt"])], + tenantColumn: "orgId", +}) + /** * The Linear issue a `linear` destination keeps for one alert thread (a rule and group). * A trigger comments on the thread's open issue, or reopens one closed within a day, @@ -325,3 +365,4 @@ export type AlertRuleStateRow = PG.SelectRowOf export type AlertIncidentRow = PG.SelectRowOf export type AlertDeliveryEventRow = PG.SelectRowOf export type AlertLinearIssueRow = PG.SelectRowOf +export type AlertSilenceRow = PG.SelectRowOf diff --git a/packages/db/src/tables/index.ts b/packages/db/src/tables/index.ts index ee1962ff46..e23bf950c2 100644 --- a/packages/db/src/tables/index.ts +++ b/packages/db/src/tables/index.ts @@ -9,6 +9,7 @@ import { AlertIncidents, AlertDeliveryEvents, AlertLinearIssues, + AlertSilences, } from "./alerts" import { AnomalyDetectorSettings, AnomalyDetectorStates, AnomalyIncidents } from "./anomalies" import { ApiKeys } from "./api-keys" @@ -164,6 +165,7 @@ export const allTables: ReadonlyArray> = [ AlertIncidents, AlertDeliveryEvents, AlertLinearIssues, + AlertSilences, AnomalyDetectorSettings, AnomalyDetectorStates, AnomalyIncidents, diff --git a/packages/domain/src/audit-actions.ts b/packages/domain/src/audit-actions.ts index 0c9b5fd48c..7b93ee1ffd 100644 --- a/packages/domain/src/audit-actions.ts +++ b/packages/domain/src/audit-actions.ts @@ -26,6 +26,7 @@ export const AuditResources = { verbs: ["created", "updated", "deleted"], }, alert_rule: { prefix: PublicIdPrefixes.alertRule, verbs: ["created", "updated", "deleted"] }, + alert_silence: { prefix: PublicIdPrefixes.alertSilence, verbs: ["created", "updated", "ended"] }, anomaly_incident: { prefix: PublicIdPrefixes.anomalyIncident, verbs: ["resolved"] }, /** Org-singleton settings, no resource id. */ anomaly_settings: { verbs: ["updated"] }, @@ -187,6 +188,7 @@ export const AuditResourceLabels = { ai_provider_key: "AI provider key", alert_destination: "Alert destination", alert_rule: "Alert rule", + alert_silence: "Alert silence", anomaly_incident: "Anomaly", anomaly_settings: "Anomaly detection", api: "API", @@ -237,6 +239,9 @@ export const AuditActionLabels = { "alert_rule.created": "Created alert rule", "alert_rule.updated": "Updated alert rule", "alert_rule.deleted": "Deleted alert rule", + "alert_silence.created": "Silenced alerts", + "alert_silence.updated": "Updated alert silence", + "alert_silence.ended": "Ended alert silence", "anomaly_incident.resolved": "Resolved anomaly", "anomaly_settings.updated": "Updated anomaly detection", "api.request": "Made API request", diff --git a/packages/domain/src/http/alert-silences.test.ts b/packages/domain/src/http/alert-silences.test.ts new file mode 100644 index 0000000000..aab668fc52 --- /dev/null +++ b/packages/domain/src/http/alert-silences.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest" +import { Schema } from "effect" +import { AlertRuleId } from "../primitives" +import { + AlertSilenceMatchers, + alertSilenceMatches, + alertSilenceStatusAt, + type AlertSilenceTarget, +} from "./alert-silences" + +const ruleId = Schema.decodeSync(AlertRuleId)("11111111-1111-4111-8111-111111111111") +const otherRuleId = Schema.decodeSync(AlertRuleId)("22222222-2222-4222-8222-222222222222") + +const rule = (overrides: Partial = {}): AlertSilenceTarget => ({ + ruleId, + tags: [], + serviceNames: [], + environments: [], + groupBy: null, + groupKey: "__total__", + ...overrides, +}) + +describe("alertSilenceMatches", () => { + const checkout = { services: ["checkout"] } + + it.each([ + ["a rule scoped to the silenced service", checkout, rule({ serviceNames: ["checkout"] }), true], + ["a rule on another service", checkout, rule({ serviceNames: ["payments"] }), false], + ["a rule over every service", checkout, rule(), false], + [ + "a rule on the silenced service and another one", + checkout, + rule({ serviceNames: ["checkout", "payments"] }), + false, + ], + [ + "a multi-service rule's incident keyed by the silenced service", + checkout, + rule({ serviceNames: ["checkout", "payments"], groupKey: "checkout" }), + true, + ], + [ + "the silenced group of a rule grouped by service", + checkout, + rule({ groupBy: ["service.name"], groupKey: "checkout" }), + true, + ], + [ + "another group of a rule grouped by service", + checkout, + rule({ groupBy: ["service.name"], groupKey: "payments" }), + false, + ], + [ + "the staging group of a rule grouped by environment", + { environments: ["staging"] }, + rule({ groupBy: ["deployment.environment"], groupKey: "staging" }), + true, + ], + [ + "a rule over every environment", + { environments: ["staging"] }, + rule({ serviceNames: ["checkout"] }), + false, + ], + ["a rule by id", { ruleIds: [ruleId] }, rule(), true], + ["another rule id", { ruleIds: [otherRuleId] }, rule(), false], + ["a rule with one silenced tag", { tags: ["db", "infra"] }, rule({ tags: ["db"] }), true], + ["a rule without the tag", { tags: ["db"] }, rule({ tags: ["web"] }), false], + [ + "a tagged production rule against tag AND environment", + { tags: ["db"], environments: ["production"] }, + rule({ tags: ["db"], environments: ["production"] }), + true, + ], + [ + "a tagged staging rule against tag AND environment", + { tags: ["db"], environments: ["production"] }, + rule({ tags: ["db"], environments: ["staging"] }), + false, + ], + ] as const)("%s → %s", (_label, matchers, target, expected) => { + expect(alertSilenceMatches(matchers, target)).toBe(expected) + }) +}) + +describe("AlertSilenceMatchers", () => { + it("rejects a silence that matches nothing in particular", () => { + expect(Schema.decodeOption(AlertSilenceMatchers)({})._tag).toBe("None") + expect(Schema.decodeOption(AlertSilenceMatchers)({ services: [] })._tag).toBe("None") + expect(Schema.decodeOption(AlertSilenceMatchers)({ services: ["checkout"] })._tag).toBe("Some") + }) +}) + +describe("alertSilenceStatusAt", () => { + it.each([ + [999, "scheduled"], + [1_000, "active"], + [1_999, "active"], + [2_000, "ended"], + ] as const)("at %s → %s", (now, status) => { + expect(alertSilenceStatusAt({ startsAtMs: 1_000, endsAtMs: 2_000 }, now)).toBe(status) + }) +}) diff --git a/packages/domain/src/http/alert-silences.ts b/packages/domain/src/http/alert-silences.ts new file mode 100644 index 0000000000..856f2fb9b3 --- /dev/null +++ b/packages/domain/src/http/alert-silences.ts @@ -0,0 +1,164 @@ +import { Array as Arr, Schema } from "effect" +import { AlertRuleId, AlertSilenceId, IsoDateTimeString, UserId } from "../primitives" +import { HttpTaggedError } from "./error-policy" +import { UNGROUPED_GROUP_KEY, type AlertGroupBy } from "./alerts" + +/** Longest a silence may run, so a forgotten one cannot mute a rule for good. */ +export const MAX_ALERT_SILENCE_DURATION_MS = 30 * 24 * 60 * 60_000 +export const MAX_ALERT_SILENCE_MATCHER_VALUES = 50 +export const MAX_ALERT_SILENCE_REASON_LENGTH = 500 + +const MatcherValue = Schema.String.check(Schema.isMinLength(1), Schema.isTrimmed(), Schema.isMaxLength(200)) +const matcherList = (item: S) => + Schema.optionalKey(Schema.Array(item).check(Schema.isMaxLength(MAX_ALERT_SILENCE_MATCHER_VALUES))) + +/** + * Which rules a silence quiets. Fields AND together, values inside a field OR. + * Services and environments match only when they cover the rule's whole scope + * (or the incident's group), so a silence on one service never hides another. + */ +export const AlertSilenceMatchers = Schema.Struct({ + ruleIds: matcherList(AlertRuleId), + tags: matcherList(MatcherValue), + services: matcherList(MatcherValue), + environments: matcherList(MatcherValue), +}) + .check( + Schema.makeFilter( + (matchers) => + (matchers.ruleIds?.length ?? 0) + + (matchers.tags?.length ?? 0) + + (matchers.services?.length ?? 0) + + (matchers.environments?.length ?? 0) > + 0 || "A silence needs at least one rule, tag, service or environment", + ), + ) + .annotate({ identifier: "@maple/AlertSilenceMatchers", title: "Alert Silence Matchers" }) +export type AlertSilenceMatchers = Schema.Schema.Type + +export const AlertSilenceStatus = Schema.Literals(["scheduled", "active", "ended"]).annotate({ + identifier: "@maple/AlertSilenceStatus", + title: "Alert Silence Status", +}) +export type AlertSilenceStatus = Schema.Schema.Type + +export const AlertSilenceReason = Schema.String.check( + Schema.isMinLength(1), + Schema.isTrimmed(), + Schema.isMaxLength(MAX_ALERT_SILENCE_REASON_LENGTH), +) + +export class AlertSilenceDocument extends Schema.Class("AlertSilenceDocument")({ + id: AlertSilenceId, + reason: Schema.String, + matchers: AlertSilenceMatchers, + status: AlertSilenceStatus, + startsAt: IsoDateTimeString, + endsAt: IsoDateTimeString, + createdBy: UserId, + createdAt: IsoDateTimeString, + updatedAt: IsoDateTimeString, +}) {} + +export class AlertSilencesListResponse extends Schema.Class( + "AlertSilencesListResponse", +)({ + silences: Schema.Array(AlertSilenceDocument), +}) {} + +/** A new silence. `startsAt` defaults to now. */ +export class AlertSilenceCreateRequest extends Schema.Class( + "AlertSilenceCreateRequest", +)({ + reason: AlertSilenceReason, + matchers: AlertSilenceMatchers, + startsAt: Schema.optionalKey(IsoDateTimeString), + endsAt: IsoDateTimeString, +}) {} + +export class AlertSilenceUpdateRequest extends Schema.Class( + "AlertSilenceUpdateRequest", +)({ + reason: Schema.optionalKey(AlertSilenceReason), + matchers: Schema.optionalKey(AlertSilenceMatchers), + startsAt: Schema.optionalKey(IsoDateTimeString), + endsAt: Schema.optionalKey(IsoDateTimeString), +}) {} + +export class AlertSilenceNotFoundError extends HttpTaggedError()( + "@maple/http/errors/AlertSilenceNotFoundError", + { message: Schema.String, silenceId: AlertSilenceId }, + { + status: 404, + code: "alert_silence_not_found", + title: "Alert silence not found", + message: "No such alert silence.", + param: "id", + retry: "never", + recovery: "none", + exposure: "redacted", + }, +) {} + +export const alertSilenceStatusAt = ( + window: { readonly startsAtMs: number; readonly endsAtMs: number }, + nowMs: number, +): AlertSilenceStatus => + nowMs < window.startsAtMs ? "scheduled" : nowMs < window.endsAtMs ? "active" : "ended" + +/** The parts of a rule and incident group a silence is matched against. */ +export interface AlertSilenceTarget { + readonly ruleId: AlertRuleId + readonly tags: ReadonlyArray + readonly serviceNames: ReadonlyArray + readonly environments: ReadonlyArray + readonly groupBy: AlertGroupBy | null + /** The incident's group; null or `__total__` for an ungrouped rule. */ + readonly groupKey: string | null +} + +const groupedBy = (target: AlertSilenceTarget, dimension: string): string | null => + target.groupKey !== null && + target.groupKey !== UNGROUPED_GROUP_KEY && + target.groupBy !== null && + target.groupBy.length === 1 && + target.groupBy[0] === dimension + ? target.groupKey + : null + +/** + * The services an incident covers: its group when the rule groups by service + * (or keys a multi-service rule by service), else the rule's scope. Empty means + * every service, which no service list covers. + */ +const scopedServices = (target: AlertSilenceTarget): ReadonlyArray => { + const group = groupedBy(target, "service.name") + if (group !== null) return [group] + if ( + target.groupKey !== null && + target.groupKey !== UNGROUPED_GROUP_KEY && + target.serviceNames.includes(target.groupKey) + ) + return [target.groupKey] + return target.serviceNames +} + +const scopedEnvironments = (target: AlertSilenceTarget): ReadonlyArray => { + const group = groupedBy(target, "deployment.environment") + return group !== null ? [group] : target.environments +} + +const covers = (silenced: ReadonlyArray | undefined, scope: ReadonlyArray): boolean => + silenced === undefined || + silenced.length === 0 || + (scope.length > 0 && Arr.every(scope, (value) => silenced.includes(value))) + +export const alertSilenceMatches = (matchers: AlertSilenceMatchers, target: AlertSilenceTarget): boolean => + (matchers.ruleIds === undefined || + matchers.ruleIds.length === 0 || + matchers.ruleIds.includes(target.ruleId)) && + (matchers.tags === undefined || + matchers.tags.length === 0 || + Arr.some(target.tags, (tag) => matchers.tags?.includes(tag) === true)) && + covers(matchers.services, scopedServices(target)) && + covers(matchers.environments, scopedEnvironments(target)) diff --git a/packages/domain/src/http/alerts.ts b/packages/domain/src/http/alerts.ts index 419fb3f8b0..988a2209da 100644 --- a/packages/domain/src/http/alerts.ts +++ b/packages/domain/src/http/alerts.ts @@ -5,6 +5,7 @@ import { AlertDestinationId, AlertIncidentId, AlertRuleId, + AlertSilenceId, ChatConnectorId, ChatWorkspaceId, ErrorIssueId, @@ -133,7 +134,14 @@ export const AlertEventType = Schema.Literals(["trigger", "resolve", "renotify", }) export type AlertEventType = Schema.Schema.Type -export const AlertDeliveryStatus = Schema.Literals(["queued", "processing", "success", "failed"]).annotate({ +/** `silenced`: written while an alert silence matched, never sent. */ +export const AlertDeliveryStatus = Schema.Literals([ + "queued", + "processing", + "success", + "failed", + "silenced", +]).annotate({ identifier: "@maple/AlertDeliveryStatus", title: "Alert Delivery Status", }) @@ -876,6 +884,10 @@ export class AlertIncidentDocument extends Schema.Class(" holdReason: Schema.NullOr(AlertIncidentHoldReason), heldSince: Schema.NullOr(IsoDateTimeString), errorIssueId: Schema.NullOr(ErrorIssueId), + /** The silence that last held back one of this incident's notifications. */ + silenceId: Schema.NullOr(AlertSilenceId), + /** Set while a silenced notification is still owed; cleared once one is sent after the silence. */ + silencedAt: Schema.NullOr(IsoDateTimeString), }) {} export class AlertIncidentsListResponse extends Schema.Class( diff --git a/packages/domain/src/http/index.ts b/packages/domain/src/http/index.ts index 2ce5160a73..165ea91278 100644 --- a/packages/domain/src/http/index.ts +++ b/packages/domain/src/http/index.ts @@ -19,6 +19,7 @@ export * from "./api-keys" export * from "./device-credentials" export * from "./audit-log" export * from "./alerts" +export * from "./alert-silences" export * from "./agent-feedback" export * from "./mcp-connections" export * from "./mcp-tools" diff --git a/packages/domain/src/http/v2/alert-incidents.ts b/packages/domain/src/http/v2/alert-incidents.ts index cb6c880c35..4bd9b477f9 100644 --- a/packages/domain/src/http/v2/alert-incidents.ts +++ b/packages/domain/src/http/v2/alert-incidents.ts @@ -14,6 +14,7 @@ import { wireExample, ListOf, ListQuery, Timestamp } from "./envelopes" import { V2ParameterInvalid } from "./errors" import { publicError } from "./public-error" import { AlertIncidentPublicId, AlertRulePublicId, ErrorIssuePublicId } from "./resource-ids" +import { AlertSilencePublicId } from "./alert-silences" const alertIncidentExample = { id: "inc_tC4d9V79DCDzgbGKhAnff9", @@ -39,6 +40,8 @@ const alertIncidentExample = { hold_reason: null, held_since: null, error_issue_id: null, + silence_id: null, + silenced_at: null, } as const // v2 wire schemas are annotated `Schema.Struct`s (not `Schema.Class`) — see the @@ -116,6 +119,14 @@ export const V2AlertIncident = Schema.Struct({ error_issue_id: Schema.NullOr(ErrorIssuePublicId).annotate({ description: "The linked error issue (`iss_…`) when the incident was correlated to one, or `null`.", }), + silence_id: Schema.NullOr(AlertSilencePublicId).annotate({ + description: + "The alert silence (`sil_…`) that last held back one of this incident's notifications, or `null`.", + }), + silenced_at: Schema.NullOr(Timestamp).annotate({ + description: + "Set while a silenced notification is still owed: the first breaching check after the silence ends sends it and clears this. `null` otherwise.", + }), }).annotate({ identifier: "AlertIncident", title: "Alert Incident", diff --git a/packages/domain/src/http/v2/alert-silences.ts b/packages/domain/src/http/v2/alert-silences.ts new file mode 100644 index 0000000000..f0acc898f5 --- /dev/null +++ b/packages/domain/src/http/v2/alert-silences.ts @@ -0,0 +1,241 @@ +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api" +import { Schema } from "effect" +import { AlertForbiddenError, AlertPersistenceError, AlertValidationError } from "../alerts" +import { + AlertSilenceNotFoundError, + AlertSilenceReason, + AlertSilenceStatus, + MAX_ALERT_SILENCE_MATCHER_VALUES, +} from "../alert-silences" +import { AlertSilenceId } from "../../primitives" +import { AuthorizationV2 } from "./auth" +import { wireExample, ListOf, ListQuery, Timestamp } from "./envelopes" +import { V2ParameterInvalid } from "./errors" +import { publicErrors } from "./public-error" +import { PublicId, PublicIdPrefixes } from "./public-id" +import { AlertRulePublicId } from "./resource-ids" + +export const AlertSilencePublicId = PublicId(PublicIdPrefixes.alertSilence, AlertSilenceId) + +const MatcherValue = Schema.String.check(Schema.isMinLength(1), Schema.isTrimmed(), Schema.isMaxLength(200)) +const matcherList = (item: S, description: string) => + Schema.optionalKey( + Schema.Array(item) + .check(Schema.isMaxLength(MAX_ALERT_SILENCE_MATCHER_VALUES)) + .annotate({ description }), + ) + +export const V2AlertSilenceMatchers = Schema.Struct({ + rule_ids: matcherList(AlertRulePublicId, "Silence these alert rules (`alrt_…`)."), + tags: matcherList(MatcherValue, "Silence rules carrying any of these tags."), + services: matcherList( + MatcherValue, + "Silence rules whose whole service scope is in this list, and the matching groups of rules grouped by `service.name`. A rule over every service is not silenced by a service list.", + ), + environments: matcherList( + MatcherValue, + "Silence rules whose whole environment scope is in this list, and the matching groups of rules grouped by `deployment.environment`.", + ), +}).annotate({ + identifier: "AlertSilenceMatchers", + title: "Alert silence matchers", + description: + "Which rules the silence covers. Fields combine with AND, values inside a field with OR. At least one value is required.", +}) +export type V2AlertSilenceMatchers = Schema.Schema.Type + +const alertSilenceExample = { + id: "sil_9Hq3cW1rN2kLmP7vX4yZtB", + object: "alert_silence", + reason: "Deploying checkout v2.3", + matchers: { services: ["checkout"] }, + status: "active", + starts_at: "2026-07-15T09:00:00.000Z", + ends_at: "2026-07-15T10:00:00.000Z", + created_by: "user_2abc", + created_at: "2026-07-15T09:00:00.000Z", + updated_at: "2026-07-15T09:00:00.000Z", +} as const + +export const V2AlertSilence = Schema.Struct({ + id: AlertSilencePublicId, + object: Schema.Literal("alert_silence").annotate({ + description: 'The object type — always `"alert_silence"`.', + examples: ["alert_silence"], + }), + reason: Schema.String.annotate({ + description: "Why notifications are silenced.", + examples: ["Deploying checkout v2.3"], + }), + matchers: V2AlertSilenceMatchers, + status: AlertSilenceStatus.annotate({ + description: "`scheduled` before `starts_at`, `active` until `ends_at`, `ended` after.", + examples: ["active"], + }), + starts_at: Timestamp.annotate({ description: "When the silence takes effect." }), + ends_at: Timestamp.annotate({ description: "When the silence ends. Ending it early moves this to now." }), + created_by: Schema.String.annotate({ description: "The user (`user_…`) who created the silence." }), + created_at: Timestamp, + updated_at: Timestamp, +}).annotate({ + identifier: "AlertSilence", + title: "Alert Silence", + description: + "A window during which matching alert rules keep evaluating and opening incidents, but send no notifications. Held-back notifications are recorded as `silenced` deliveries. When it ends on a still-open incident, the next breaching check sends the page once; a resolve during the silence reaches only destinations paged before it began.", + examples: [wireExample(alertSilenceExample)], +}) +export type V2AlertSilence = Schema.Schema.Type + +const DurationMinutes = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 30 * 24 * 60 })).annotate({ + description: "How long the silence lasts from `starts_at`, in minutes. Use instead of `ends_at`.", + examples: [60], +}) + +export const V2AlertSilenceCreateParams = Schema.Struct({ + reason: AlertSilenceReason.annotate({ + description: "Why notifications are silenced.", + examples: ["Deploying checkout v2.3"], + }), + matchers: V2AlertSilenceMatchers, + starts_at: Schema.optionalKey( + Timestamp.annotate({ description: "When it takes effect. Defaults to now." }), + ), + ends_at: Schema.optionalKey( + Timestamp.annotate({ description: "When it ends. Give this or `duration_minutes`." }), + ), + duration_minutes: Schema.optionalKey(DurationMinutes), +}).annotate({ + identifier: "AlertSilenceCreateParams", + title: "Create alert silence", + description: "A silence lasts at most 30 days. Give exactly one of `ends_at` and `duration_minutes`.", +}) +export type V2AlertSilenceCreateParams = Schema.Schema.Type + +export const V2AlertSilenceUpdateParams = Schema.Struct({ + reason: Schema.optionalKey(AlertSilenceReason), + matchers: Schema.optionalKey(V2AlertSilenceMatchers), + starts_at: Schema.optionalKey( + Timestamp.annotate({ description: "Only before the silence has started." }), + ), + ends_at: Schema.optionalKey(Timestamp.annotate({ description: "Extend or shorten the silence." })), +}).annotate({ + identifier: "AlertSilenceUpdateParams", + title: "Update alert silence", + description: "Change an active or scheduled silence. An ended silence cannot be changed.", +}) +export type V2AlertSilenceUpdateParams = Schema.Schema.Type + +const SilencesQuery = Schema.Struct({ + ...ListQuery.fields, + status: Schema.optional( + AlertSilenceStatus.annotate({ + description: "Only return silences in this status.", + examples: ["active"], + }), + ), +}).annotate({ + identifier: "AlertSilenceListQuery", + title: "Alert silence list query", + description: "Pagination plus an optional status filter.", +}) + +const AlertSilenceList = ListOf(V2AlertSilence).annotate({ + identifier: "AlertSilenceList", + title: "Alert silence list", + description: "A cursor-paginated page of alert silences, latest ending first.", +}) + +const [alertForbidden, alertValidation, alertPersistence, alertSilenceNotFound] = publicErrors( + AlertForbiddenError, + AlertValidationError, + AlertPersistenceError, + AlertSilenceNotFoundError, +) + +export class V2AlertSilencesApiGroup extends HttpApiGroup.make("alertSilences") + .add( + HttpApiEndpoint.get("list", "/", { + query: SilencesQuery, + success: AlertSilenceList, + error: [V2ParameterInvalid.schema, alertPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "listAlertSilences", + summary: "List alert silences", + description: + "Returns your organization's alert silences, latest ending first, optionally filtered by `status`. Requires the `alerts:read` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.get("retrieve", "/:id", { + params: { id: AlertSilencePublicId }, + success: V2AlertSilence, + error: [alertSilenceNotFound, alertPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "getAlertSilence", + summary: "Retrieve an alert silence", + description: + "Returns a single alert silence by its `sil_…` ID. Requires the `alerts:read` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.post("create", "/", { + payload: V2AlertSilenceCreateParams, + success: V2AlertSilence, + error: [V2ParameterInvalid.schema, alertForbidden, alertValidation, alertPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "createAlertSilence", + summary: "Create an alert silence", + description: + "Silences notifications for matching rules from `starts_at` (default now) until `ends_at`. Requires a role that can manage alerts and the `alerts:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.patch("update", "/:id", { + params: { id: AlertSilencePublicId }, + payload: V2AlertSilenceUpdateParams, + success: V2AlertSilence, + error: [ + V2ParameterInvalid.schema, + alertForbidden, + alertValidation, + alertPersistence, + alertSilenceNotFound, + ], + }).annotateMerge( + OpenApi.annotations({ + identifier: "updateAlertSilence", + summary: "Update an alert silence", + description: + "Changes the reason, matchers or window of an active or scheduled silence. Requires the `alerts:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.post("end", "/:id/end", { + params: { id: AlertSilencePublicId }, + success: V2AlertSilence, + error: [alertForbidden, alertPersistence, alertSilenceNotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "endAlertSilence", + summary: "End an alert silence", + description: + "Ends an active silence now, or cancels a scheduled one. Silences are kept, not deleted, so incident history can still name them. Requires the `alerts:write` scope.", + }), + ), + ) + .prefix("/v2/alerts/silences") + .middleware(AuthorizationV2) + .annotateMerge( + OpenApi.annotations({ + title: "Alert Silences", + description: + "Maintenance windows for alerting: quiet a deploy or a planned migration without disabling rules. Rules keep evaluating and incidents still open and resolve; only notifications are held back.", + }), + ) {} diff --git a/packages/domain/src/http/v2/api.ts b/packages/domain/src/http/v2/api.ts index 6ab2a987bb..5cee52ca5e 100644 --- a/packages/domain/src/http/v2/api.ts +++ b/packages/domain/src/http/v2/api.ts @@ -3,6 +3,7 @@ import { V2AlertDeliveriesApiGroup } from "./alert-deliveries" import { V2AlertDestinationsApiGroup } from "./alert-destinations" import { V2AnomaliesApiGroup } from "./anomalies" import { V2AlertIncidentsApiGroup } from "./alert-incidents" +import { V2AlertSilencesApiGroup } from "./alert-silences" import { V2AlertRulesApiGroup } from "./alert-rules" import { V2ApiKeysApiGroup } from "./api-keys" import { V2AttributeMappingsApiGroup } from "./attribute-mappings" @@ -104,6 +105,7 @@ export class MapleApiV2 extends HttpApi.make("MapleApiV2") .add(V2AlertDeliveriesApiGroup) .add(V2AlertDestinationsApiGroup) .add(V2AlertIncidentsApiGroup) + .add(V2AlertSilencesApiGroup) .add(V2IngestKeysApiGroup) .add(V2SourcesApiGroup) .add(V2ChatIntegrationsApiGroup) diff --git a/packages/domain/src/http/v2/index.ts b/packages/domain/src/http/v2/index.ts index 574394f678..93d91ca25f 100644 --- a/packages/domain/src/http/v2/index.ts +++ b/packages/domain/src/http/v2/index.ts @@ -2,6 +2,7 @@ export * from "./alert-deliveries" export * from "./alert-destinations" export * from "./alert-incidents" export * from "./alert-rules" +export * from "./alert-silences" export * from "./anomalies" export * from "./api" export * from "./api-keys" diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index eec28a2b0d..691d48e135 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -115,6 +115,8 @@ describe("MapleApiV2 OpenAPI", () => { "GET /v2/alerts/rules/{id}", "GET /v2/alerts/rules/{id}/checks", "GET /v2/alerts/rules/{id}/checks/summary", + "GET /v2/alerts/silences", + "GET /v2/alerts/silences/{id}", "GET /v2/anomalies/incidents", "GET /v2/anomalies/incidents/service_counts", "GET /v2/anomalies/incidents/{id}", @@ -196,6 +198,7 @@ describe("MapleApiV2 OpenAPI", () => { "GET /v2/widget_summary", "PATCH /v2/alerts/destinations/{id}", "PATCH /v2/alerts/rules/{id}", + "PATCH /v2/alerts/silences/{id}", "PATCH /v2/anomalies/settings", "PATCH /v2/attribute_mappings/{id}", "PATCH /v2/dashboards/{id}", @@ -216,6 +219,8 @@ describe("MapleApiV2 OpenAPI", () => { "POST /v2/alerts/rules", "POST /v2/alerts/rules/preview", "POST /v2/alerts/rules/test", + "POST /v2/alerts/silences", + "POST /v2/alerts/silences/{id}/end", "POST /v2/anomalies/incidents/{id}/resolve", "POST /v2/api_keys", "POST /v2/api_keys/{id}/roll", diff --git a/packages/domain/src/http/v2/public-id.ts b/packages/domain/src/http/v2/public-id.ts index 086531fd75..38e0acbd96 100644 --- a/packages/domain/src/http/v2/public-id.ts +++ b/packages/domain/src/http/v2/public-id.ts @@ -36,6 +36,7 @@ export const PublicIdPrefixes = { alertRule: "alrt", alertDestination: "dest", alertIncident: "inc", + alertSilence: "sil", actor: "actor", auditLogEntry: "alog", errorIssue: "iss", diff --git a/packages/domain/src/http/v2/v2-contract.test.ts b/packages/domain/src/http/v2/v2-contract.test.ts index 14c121983b..2fbcf3b496 100644 --- a/packages/domain/src/http/v2/v2-contract.test.ts +++ b/packages/domain/src/http/v2/v2-contract.test.ts @@ -479,6 +479,8 @@ describe("V2 alerts wire format", () => { hold_reason: null, held_since: null, error_issue_id: null, + silence_id: null, + silenced_at: null, }) expect(incident.id).toBe(INCIDENT_UUID) expect(incident.rule_id).toBe(UUID) diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index e03ddb1d5a..14b8aefec4 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -196,6 +196,9 @@ export type AlertIncidentId = Schema.Schema.Type export const AlertDeliveryEventId = MapleUuidId("@maple/AlertDeliveryEventId", "Alert Delivery Event ID") export type AlertDeliveryEventId = Schema.Schema.Type +export const AlertSilenceId = MapleUuidId("@maple/AlertSilenceId", "Alert Silence ID") +export type AlertSilenceId = Schema.Schema.Type + export const MobileDeviceId = MapleUuidId("@maple/MobileDeviceId", "Mobile Device ID") export type MobileDeviceId = Schema.Schema.Type diff --git a/packages/web-data/src/alerts/form-utils.ts b/packages/web-data/src/alerts/form-utils.ts index 8826c3ad0e..6f6642c45e 100644 --- a/packages/web-data/src/alerts/form-utils.ts +++ b/packages/web-data/src/alerts/form-utils.ts @@ -1103,6 +1103,7 @@ export const deliveryStatusMeta: Record< failed: { label: "Failed", variant: "crit" }, processing: { label: "Sending", variant: "warn" }, queued: { label: "Queued", variant: "outline" }, + silenced: { label: "Silenced", variant: "outline" }, } satisfies Record export interface DeliveryEventDayGroup { diff --git a/packages/web-data/src/alerts/headroom.test.ts b/packages/web-data/src/alerts/headroom.test.ts index 4ce6530efb..75135ad26c 100644 --- a/packages/web-data/src/alerts/headroom.test.ts +++ b/packages/web-data/src/alerts/headroom.test.ts @@ -130,6 +130,8 @@ describe("quietForMs", () => { holdReason: null, heldSince: null, errorIssueId: null, + silenceId: null, + silencedAt: null, }) it("counts from the latest resolve", () => { diff --git a/packages/web-data/src/alerts/incident-timeline.test.ts b/packages/web-data/src/alerts/incident-timeline.test.ts index a8f47bbf17..d2e1fcd499 100644 --- a/packages/web-data/src/alerts/incident-timeline.test.ts +++ b/packages/web-data/src/alerts/incident-timeline.test.ts @@ -33,6 +33,8 @@ function makeIncident(overrides: Record = {}): AlertIncidentDoc holdReason: null, heldSince: null, errorIssueId: null, + silenceId: null, + silencedAt: null, ...overrides, }) } diff --git a/packages/web-data/src/alerts/overview-derive.test.ts b/packages/web-data/src/alerts/overview-derive.test.ts index 21307a8acc..dd6c9a2286 100644 --- a/packages/web-data/src/alerts/overview-derive.test.ts +++ b/packages/web-data/src/alerts/overview-derive.test.ts @@ -97,6 +97,8 @@ function makeIncident(overrides: Record = {}): AlertIncidentDoc holdReason: null, heldSince: null, errorIssueId: null, + silenceId: null, + silencedAt: null, ...overrides, }) } diff --git a/packages/web-data/src/alerts/overview.test.ts b/packages/web-data/src/alerts/overview.test.ts index a06be184cd..a20dd3b3cf 100644 --- a/packages/web-data/src/alerts/overview.test.ts +++ b/packages/web-data/src/alerts/overview.test.ts @@ -85,6 +85,8 @@ function makeIncident(overrides: Record = {}): AlertIncidentDoc holdReason: null, heldSince: null, errorIssueId: null, + silenceId: null, + silencedAt: null, ...overrides, }) } diff --git a/packages/web-data/src/alerts/preview-insights.test.ts b/packages/web-data/src/alerts/preview-insights.test.ts index cd3d04ed3e..9bd08d7e63 100644 --- a/packages/web-data/src/alerts/preview-insights.test.ts +++ b/packages/web-data/src/alerts/preview-insights.test.ts @@ -76,6 +76,8 @@ function incident(groupKey: string | null, status = "open"): AlertIncidentDocume holdReason: null, heldSince: null, errorIssueId: null, + silenceId: null, + silencedAt: null, }) } diff --git a/packages/web-data/src/collections/alerts.ts b/packages/web-data/src/collections/alerts.ts index b8f01102ec..72d416314d 100644 --- a/packages/web-data/src/collections/alerts.ts +++ b/packages/web-data/src/collections/alerts.ts @@ -19,6 +19,7 @@ import { ChatConnectorId, ChatWorkspaceId, ErrorIssueId, + AlertSilenceId, IsoDateTimeString, QueryBuilderQueryDraftSchema, UserId, @@ -34,6 +35,7 @@ const asAlertRuleId = Schema.decodeUnknownSync(AlertRuleId) const asAlertIncidentId = Schema.decodeUnknownSync(AlertIncidentDocument.fields.id) const asUserId = Schema.decodeUnknownSync(UserId) const asErrorIssueId = Schema.decodeUnknownSync(ErrorIssueId) +const asAlertSilenceId = Schema.decodeUnknownSync(AlertSilenceId) const decodeDestinationId = Schema.decodeUnknownSync(AlertDestinationId) const decodeDestinationType = Schema.decodeUnknownOption(AlertDestinationType) @@ -165,6 +167,8 @@ export const AlertIncidentRowSchema = Schema.Struct({ hold_reason: Schema.NullOr(Schema.String), held_since: Schema.NullOr(Schema.String), error_issue_id: Schema.NullOr(Schema.String), + silence_id: Schema.optionalKey(Schema.NullOr(Schema.String)), + silenced_at: Schema.optionalKey(Schema.NullOr(Schema.String)), created_at: Schema.String, updated_at: Schema.String, }) @@ -294,6 +298,8 @@ export const rowToAlertIncidentDocument = (row: AlertIncidentRow): AlertIncident holdReason: row.hold_reason != null ? asHoldReason(row.hold_reason) : null, heldSince: row.held_since != null ? decodeIso(row.held_since) : null, errorIssueId: row.error_issue_id != null ? asErrorIssueId(row.error_issue_id) : null, + silenceId: row.silence_id == null ? null : asAlertSilenceId(row.silence_id), + silencedAt: row.silenced_at == null ? null : decodeIso(row.silenced_at), }) // alert_destinations From d7e995bfa7aab3e68dbc1a70827846091eb0fb55 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 01:09:50 +0100 Subject: [PATCH 02/28] feat(alerts): MCP tools for alert silences Adds list_alert_silences, create_alert_silence and end_alert_silence on AlertSilencesService, and makes get_incident_timeline name the silence holding back an incident's notifications. --- apps/ai/src/mcp/dispatcher.ts | 2 + apps/ai/src/mcp/lib/alert-silences.ts | 86 ++++++ apps/ai/src/mcp/lib/params.ts | 9 +- apps/ai/src/mcp/resources/instructions.ts | 1 + apps/ai/src/mcp/tool-descriptors.generated.ts | 5 +- .../__tests__/alert-silence-tools.test.ts | 259 ++++++++++++++++++ apps/ai/src/mcp/tools/create-alert-silence.ts | 117 ++++++++ apps/ai/src/mcp/tools/end-alert-silence.ts | 37 +++ .../ai/src/mcp/tools/get-incident-timeline.ts | 48 +++- apps/ai/src/mcp/tools/list-alert-silences.ts | 114 ++++++++ apps/ai/src/mcp/tools/mutating.ts | 2 + apps/ai/src/mcp/tools/registry.ts | 6 + apps/ai/src/mcp/tools/runtime-requirements.ts | 2 + .../test/mcp/unimplemented-tool-services.ts | 2 + .../landing/src/content/docs/reference/mcp.md | 3 + .../conversation/kit/tool-metadata.ts | 6 + .../domain/src/mcp-outputs/alert-silences.ts | 33 +++ packages/domain/src/mcp-outputs/alerts.ts | 6 + packages/domain/src/mcp-outputs/catalog.ts | 4 + packages/domain/src/mcp-outputs/index.ts | 1 + 20 files changed, 740 insertions(+), 3 deletions(-) create mode 100644 apps/ai/src/mcp/lib/alert-silences.ts create mode 100644 apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts create mode 100644 apps/ai/src/mcp/tools/create-alert-silence.ts create mode 100644 apps/ai/src/mcp/tools/end-alert-silence.ts create mode 100644 apps/ai/src/mcp/tools/list-alert-silences.ts create mode 100644 packages/domain/src/mcp-outputs/alert-silences.ts diff --git a/apps/ai/src/mcp/dispatcher.ts b/apps/ai/src/mcp/dispatcher.ts index bdcf068717..7962945ac9 100644 --- a/apps/ai/src/mcp/dispatcher.ts +++ b/apps/ai/src/mcp/dispatcher.ts @@ -11,6 +11,7 @@ import { AlertsService } from "@maple/backend/services/alerts/AlertsService" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" 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" @@ -270,6 +271,7 @@ const McpRuntimeServicesLive = Layer.mergeAll( AgentFeedbackService.layer, AlertReadModelsService.layer, AlertRulesService.layer, + AlertSilencesService.layer, AlertsService.layer, AuditLogService.layer, DashboardPersistenceService.layer, diff --git a/apps/ai/src/mcp/lib/alert-silences.ts b/apps/ai/src/mcp/lib/alert-silences.ts new file mode 100644 index 0000000000..72174139f1 --- /dev/null +++ b/apps/ai/src/mcp/lib/alert-silences.ts @@ -0,0 +1,86 @@ +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 | 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 = { + "@maple/http/errors/AlertValidationError": (error: AlertValidationError) => + Effect.fail(new McpInvalidInputError({ message: `Invalid alert silence: ${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"), +} diff --git a/apps/ai/src/mcp/lib/params.ts b/apps/ai/src/mcp/lib/params.ts index 78b777e545..53c1c30d8b 100644 --- a/apps/ai/src/mcp/lib/params.ts +++ b/apps/ai/src/mcp/lib/params.ts @@ -18,7 +18,13 @@ import { String as Str, } from "effect" import { decodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" -import { AlertDestinationId, AlertRuleId, DashboardId, ErrorIssueId } from "@maple/domain/primitives" +import { + AlertDestinationId, + AlertRuleId, + AlertSilenceId, + DashboardId, + ErrorIssueId, +} from "@maple/domain/primitives" import { WarehouseTimeInput, type WarehouseDateTime } from "@maple/query-engine" import { McpInvalidInputError } from "../tools/types" import { resolveWindow } from "./time" @@ -146,6 +152,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 diff --git a/apps/ai/src/mcp/resources/instructions.ts b/apps/ai/src/mcp/resources/instructions.ts index 8c69604cab..516be3b6ea 100644 --- a/apps/ai/src/mcp/resources/instructions.ts +++ b/apps/ai/src/mcp/resources/instructions.ts @@ -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. - 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. - AI agent sessions (LLM agent traces — NOT browser replays): list_agent_sessions -> get_agent_session (verdict and checks) -> get_agent_session_transcript (what it did step by step; \`failed_only\`, \`turn\`) -> inspect_span (one span in full) diff --git a/apps/ai/src/mcp/tool-descriptors.generated.ts b/apps/ai/src/mcp/tool-descriptors.generated.ts index cc79e1ed5e..616c0c16fd 100644 --- a/apps/ai/src/mcp/tool-descriptors.generated.ts +++ b/apps/ai/src/mcp/tool-descriptors.generated.ts @@ -33,10 +33,13 @@ export const MCP_TOOL_DESCRIPTORS: ReadonlyArray = [ {"name":"list_alert_incidents","title":"List Alert Incidents","description":"Alert incidents: alert-rule breaches, open and resolved, with severity, group, condition and last observed value (flare-ups of an error issue are list_error_incidents). For one rule's trigger, notify and resolve timestamps use get_incident_timeline.","inputSchema":{"type":"object","properties":{"status":{"type":"string","enum":["open","resolved"],"description":"Only incidents in this status"},"severity":{"type":"string","enum":["warning","critical"],"description":"Only incidents with this severity"},"group_key":{"type":"string","description":"Only incidents with this exact group key"},"limit":{"type":"number","description":"Max incidents to return (default 50, max 200)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"incidents":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"ruleId":{"type":"string"},"ruleName":{"type":"string"},"groupKey":{"anyOf":[{"type":"string"},{"type":"null"}]},"signalType":{"type":"string"},"severity":{"type":"string"},"status":{"type":"string"},"holdReason":{"anyOf":[{"type":"string"},{"type":"null"}]},"heldSince":{"anyOf":[{"type":"string"},{"type":"null"}]},"threshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"comparator":{"type":"string"},"firstTriggeredAt":{"type":"string"},"resolvedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastObservedValue":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]}},"required":["id","ruleId","ruleName","groupKey","signalType","severity","status","holdReason","heldSince","threshold","comparator","firstTriggeredAt","resolvedAt","lastObservedValue"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"openCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"resolvedCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"recentlyResolvedCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"status":{"type":"string"},"severity":{"type":"string"},"groupKey":{"type":"string"},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"hasMore":{"type":"boolean"}},"required":["incidents","total","openCount","resolvedCount"],"additionalProperties":true},"annotations":{"title":"List Alert Incidents","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"list_alert_checks","title":"List Alert Checks","description":"List a rule's recent checks (one row per evaluation) with observed value, threshold, sample count and incident transition. Use it to tune thresholds, diagnose flapping, or see the near-misses before a breach.","inputSchema":{"type":"object","properties":{"rule_id":{"type":"string","description":"Alert rule ID: its UUID or its REST id (alrt_\u2026)"},"group_key":{"type":"string","description":"Only checks for this exact group key"},"status":{"type":"string","enum":["breached","healthy","skipped","error"],"description":"Only checks with this status"},"start_time":{"type":"string","description":"Window start, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601). Default: 7 days before end_time."},"end_time":{"type":"string","description":"Window end, UTC, same format. Default: now."},"limit":{"type":"number","description":"Max checks to return (default 100, max 2000)"}},"required":["rule_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"ruleId":{"type":"string"},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"breached":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"healthy":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"skipped":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"noData":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"belowMinSamples":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"errored":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"transitions":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"checks":{"type":"array","items":{"type":"object","properties":{"timestamp":{"type":"string"},"groupKey":{"type":"string"},"status":{"type":"string"},"skipReason":{"anyOf":[{"type":"string"},{"type":"null"}]},"observedValue":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"threshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"comparator":{"type":"string"},"sampleCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowStart":{"type":"string"},"windowEnd":{"type":"string"},"consecutiveBreaches":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"consecutiveHealthy":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"incidentId":{"anyOf":[{"type":"string"},{"type":"null"}]},"incidentTransition":{"type":"string"},"evaluationDurationMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"errorMessage":{"anyOf":[{"type":"string"},{"type":"null"}]},"errorCategory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["timestamp","groupKey","status","skipReason","observedValue","threshold","comparator","sampleCount","windowStart","windowEnd","consecutiveBreaches","consecutiveHealthy","incidentId","incidentTransition","evaluationDurationMs","errorMessage","errorCategory"],"additionalProperties":true}},"timeRange":{"type":"object","properties":{"start":{"type":"string"},"end":{"type":"string"}},"required":["start","end"],"additionalProperties":true},"groupKey":{"type":"string"},"status":{"type":"string"},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["ruleId","total","breached","healthy","skipped","noData","belowMinSamples","errored","transitions","checks"],"additionalProperties":true},"annotations":{"title":"List Alert Checks","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"preview_alert_rule","title":"Preview Alert Rule","description":"Replay an alert rule over past data without saving it: per evaluation window, the value, sample count and verdict the scheduler would produce (breached, healthy, or skipped with the reason), plus when it would have fired. Pass rule_id to preview a saved rule, optionally with overrides, or a create_alert_rule definition to dry-run a new one. Run it before saving a raw_query rule.","inputSchema":{"type":"object","properties":{"rule_id":{"type":"string","description":"Existing rule to preview (list_alert_rules). Definition params you also pass override its saved config, so a change can be tried before update_alert_rule. `template` is ignored with rule_id.: its UUID or its REST id (alrt_\u2026)"},"template":{"type":"string","enum":["high_error_rate","slow_p95","slow_p99","low_apdex","throughput_drop","custom"],"description":"Preset for signal_type, comparator and threshold; signal_type and comparator you pass are ignored, threshold overrides. high_error_rate: error_rate > 0.05, grouped by service.name unless services is set. slow_p95: p95_latency > 1000 ms. slow_p99: p99_latency > 2000 ms. low_apdex: apdex < 0.8 with apdex_threshold_ms 500. throughput_drop: throughput < 100 rpm. Omit (or pass custom) to set the three yourself."},"threshold":{"type":"number","description":"The value compared against. Units: error_rate and apdex are 0-1 ratios, latency is milliseconds, throughput is requests per minute. Overrides the template's."},"window_minutes":{"type":"number","description":"Evaluation window in minutes, at most 1440 (default: 5)"},"evaluation_interval_minutes":{"anyOf":[{"type":"number","enum":[1,5,10,15,30,60]},{"type":"string"},{"type":"string","enum":["auto"]}],"description":"Minutes between checks, at most window_minutes. A raw_query rule checked less often gets a longer query time limit: 5s under 5 min, 15s at 5, 25s at 10 or more. Omit (or pass \"auto\") to derive it: a fifth of the window, 1 to 5."},"services":{"type":"array","items":{"type":"string"},"description":"Service names to scope the alert to"},"environments":{"type":"array","items":{"type":"string"},"description":"Deployment environments to scope the alert to (e.g. 'production'). Omit for all environments. Ignored for builder_query / raw_query, which filter inside their own query."},"signal_type":{"type":"string","enum":["error_rate","p95_latency","p99_latency","apdex","throughput","builder_query","raw_query"],"description":"What the rule measures. builder_query takes query_builder_draft, raw_query takes raw_query_sql. All other signals are computed over ROOT spans only: a service that fails on child spans but returns success from its entry point (cron jobs, workers; audit_setup STAT-04 lists them) reads healthy at any threshold. Use raw_query there or rely on error-issue notifications."},"comparator":{"type":"string","enum":["gt","gte","lt","lte","eq","neq"],"description":"How the observed value is compared with threshold (breach when `value threshold`). Ignored when a template is set."},"group_by":{"type":"array","items":{"type":"string"},"description":"Evaluate one value per group. Built-in tokens: service.name, span.name, status.code, http.method, severity; attribute keys as attr. (e.g. attr.http.route)."},"minimum_sample_count":{"type":"number","description":"Skip evaluation below this many samples in the window (default: 0). For raw_query it sums the `samples` column; without one each returned row counts as 1, so it gates on buckets, not events."},"alert_on_no_data":{"type":"boolean","description":"Count a window with no data as a breach, so a rule whose query stops matching opens an incident instead of going quiet (default false: such windows are skipped). Not supported with group_by."},"consecutive_breaches":{"type":"number","description":"Consecutive breaches before alerting (default: 2)"},"consecutive_healthy":{"type":"number","description":"Consecutive healthy evaluations before resolving (default: 2)"},"renotify_interval_minutes":{"type":"number","description":"Re-notification interval in minutes (default: 30)"},"apdex_threshold_ms":{"type":"number","description":"Response time counted as satisfactory, in ms. Required for signal_type=apdex."},"query_builder_draft":{"type":"string","contentMediaType":"application/json","contentSchema":{"anyOf":[{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["traces"]},"valueField":{"type":"string"}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["logs"]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["metrics"]},"signalSource":{"type":"string","enum":["default","meter"]},"metricName":{"type":"string"},"metricType":{"type":"string","enum":["sum","gauge","histogram","exponential_histogram"]},"isMonotonic":{"type":"boolean"}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["product_events"]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false}]},"description":"The query to evaluate, in the query-builder draft shape dashboard custom-query widgets use ({ id, name, dataSource, aggregation, whereClause, groupBy, ... }). Required for signal_type=builder_query."},"raw_query_sql":{"type":"string","description":"ClickHouse SQL returning a numeric `value` column and optional `group` / `samples` columns (`samples` is the event count behind each row; it feeds minimum_sample_count). A query returning no rows is a no-data check, not a healthy one. Must reference $__orgFilter and $__timeFilter(col); $__startTime, $__endTime and $__interval_s are also available. At most 100 distinct groups. Required for signal_type=raw_query."},"raw_query_reducer":{"type":"string","enum":["identity","sum","avg","max","min"],"description":"How several raw_query rows collapse into one value (default identity: the single row's value)."},"start_time":{"type":"string","description":"Window start, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601). Default: 1 day before end_time. Covers at most 7 days; a wider window is narrowed to the 7 days before end_time."},"end_time":{"type":"string","description":"Window end, UTC, same format. Default: now."}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"ruleId":{"type":"string"},"timeRange":{"type":"object","properties":{"start":{"type":"string"},"end":{"type":"string"}},"required":["start","end"],"additionalProperties":true},"truncatedToStart":{"anyOf":[{"type":"string"},{"type":"null"}]},"windowMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"comparator":{"type":"string"},"threshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"thresholdUpper":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"minimumSampleCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"groups":{"type":"array","items":{"type":"object","properties":{"groupKey":{"type":"string"},"windows":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"breached":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"healthy":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"noData":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"belowMinSamples":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"noValue":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"minValue":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"maxValue":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"totalSamples":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["groupKey","windows","breached","healthy","noData","belowMinSamples","noValue","minValue","maxValue","totalSamples"],"additionalProperties":true}},"points":{"type":"array","items":{"type":"object","properties":{"groupKey":{"type":"string"},"bucket":{"type":"string"},"status":{"type":"string"},"skipReason":{"anyOf":[{"type":"string"},{"type":"null"}]},"value":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"sampleCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"provisional":{"type":"boolean"}},"required":["groupKey","bucket","status","skipReason","value","sampleCount","provisional"],"additionalProperties":true}},"wouldFire":{"type":"array","items":{"type":"object","properties":{"groupKey":{"type":"string"},"start":{"type":"string"},"end":{"type":"string"}},"required":["groupKey","start","end"],"additionalProperties":true}},"warnings":{"type":"array","items":{"type":"string"}},"seriesFailure":{"type":"string","enum":["query_too_slow","query_too_much_memory","not_time_grouped"]},"queryTiming":{"type":"object","properties":{"elapsedMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"failure":{"anyOf":[{"type":"string"},{"type":"null"}]},"probeLimitMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"intervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"limitMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"fitsIntervalMinutes":{"type":"array","items":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}}},"required":["elapsedMs","failure","probeLimitMs","intervalMinutes","limitMs","fitsIntervalMinutes"],"additionalProperties":true},"performanceHints":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"line":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["code","message","line"],"additionalProperties":true}}},"required":["timeRange","truncatedToStart","windowMinutes","comparator","threshold","thresholdUpper","minimumSampleCount","groups","points","wouldFire","warnings"],"additionalProperties":true},"annotations":{"title":"Preview Alert Rule","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, - {"name":"get_incident_timeline","title":"Incident Timeline","description":"Alert-rule incidents (not error-issue incidents) for one rule or all, with first and last trigger, last notification and resolution timestamps: the per-incident detail behind list_alert_incidents. Pass rule_id to follow one rule over time.","inputSchema":{"type":"object","properties":{"rule_id":{"type":"string","description":"Only incidents of this rule (ids from list_alert_rules): its UUID or its REST id (alrt_\u2026)"},"status":{"type":"string","enum":["open","resolved"],"description":"Only incidents in this status"},"severity":{"type":"string","enum":["warning","critical"],"description":"Only incidents with this severity"},"group_key":{"type":"string","description":"Only incidents with this exact group key"},"limit":{"type":"number","description":"Max incidents to return (default 20, max 200)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"incidents":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"ruleId":{"type":"string"},"ruleName":{"type":"string"},"groupKey":{"anyOf":[{"type":"string"},{"type":"null"}]},"signalType":{"type":"string"},"severity":{"type":"string"},"status":{"type":"string"},"comparator":{"type":"string"},"threshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"lastObservedValue":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"firstTriggeredAt":{"type":"string"},"lastTriggeredAt":{"type":"string"},"resolvedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastNotifiedAt":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","ruleId","ruleName","groupKey","signalType","severity","status","comparator","threshold","lastObservedValue","firstTriggeredAt","lastTriggeredAt","resolvedAt","lastNotifiedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"openCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"resolvedCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleId":{"type":"string"},"status":{"type":"string"},"severity":{"type":"string"},"groupKey":{"type":"string"},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"hasMore":{"type":"boolean"}},"required":["incidents","total","openCount","resolvedCount"],"additionalProperties":true},"annotations":{"title":"Incident Timeline","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"get_incident_timeline","title":"Incident Timeline","description":"Alert-rule incidents (not error-issue incidents) for one rule or all, with first and last trigger, last notification and resolution timestamps: the per-incident detail behind list_alert_incidents. Pass rule_id to follow one rule over time.","inputSchema":{"type":"object","properties":{"rule_id":{"type":"string","description":"Only incidents of this rule (ids from list_alert_rules): its UUID or its REST id (alrt_\u2026)"},"status":{"type":"string","enum":["open","resolved"],"description":"Only incidents in this status"},"severity":{"type":"string","enum":["warning","critical"],"description":"Only incidents with this severity"},"group_key":{"type":"string","description":"Only incidents with this exact group key"},"limit":{"type":"number","description":"Max incidents to return (default 20, max 200)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"incidents":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"ruleId":{"type":"string"},"ruleName":{"type":"string"},"groupKey":{"anyOf":[{"type":"string"},{"type":"null"}]},"signalType":{"type":"string"},"severity":{"type":"string"},"status":{"type":"string"},"comparator":{"type":"string"},"threshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"lastObservedValue":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"firstTriggeredAt":{"type":"string"},"lastTriggeredAt":{"type":"string"},"resolvedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastNotifiedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"silenceId":{"anyOf":[{"type":"string"},{"type":"null"}]},"silencedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"silenceReason":{"type":"string"}},"required":["id","ruleId","ruleName","groupKey","signalType","severity","status","comparator","threshold","lastObservedValue","firstTriggeredAt","lastTriggeredAt","resolvedAt","lastNotifiedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"openCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"resolvedCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleId":{"type":"string"},"status":{"type":"string"},"severity":{"type":"string"},"groupKey":{"type":"string"},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"hasMore":{"type":"boolean"}},"required":["incidents","total","openCount","resolvedCount"],"additionalProperties":true},"annotations":{"title":"Incident Timeline","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"create_alert_rule","title":"Create Alert Rule","description":"Create an alert rule. Pick a `template` for the common cases; otherwise pass signal_type, comparator and threshold yourself. destination_ids is required (at least one, from list_alert_destinations).","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"Rule name"},"destination_ids":{"type":"array","items":{"type":"string"},"description":"Required, at least one: destination IDs to notify, from list_alert_destinations. Each is its UUID or its REST id (dest_\u2026)"},"template":{"type":"string","enum":["high_error_rate","slow_p95","slow_p99","low_apdex","throughput_drop","custom"],"description":"Preset for signal_type, comparator and threshold; signal_type and comparator you pass are ignored, threshold overrides. high_error_rate: error_rate > 0.05, grouped by service.name unless services is set. slow_p95: p95_latency > 1000 ms. slow_p99: p99_latency > 2000 ms. low_apdex: apdex < 0.8 with apdex_threshold_ms 500. throughput_drop: throughput < 100 rpm. Omit (or pass custom) to set the three yourself."},"severity":{"type":"string","enum":["warning","critical"],"description":"Alert severity (default: warning)"},"threshold":{"type":"number","description":"The value compared against. Units: error_rate and apdex are 0-1 ratios, latency is milliseconds, throughput is requests per minute. Overrides the template's."},"window_minutes":{"type":"number","description":"Evaluation window in minutes, at most 1440 (default: 5)"},"evaluation_interval_minutes":{"anyOf":[{"type":"number","enum":[1,5,10,15,30,60]},{"type":"string"},{"type":"string","enum":["auto"]}],"description":"Minutes between checks, at most window_minutes. A raw_query rule checked less often gets a longer query time limit: 5s under 5 min, 15s at 5, 25s at 10 or more. Omit (or pass \"auto\") to derive it: a fifth of the window, 1 to 5."},"services":{"type":"array","items":{"type":"string"},"description":"Service names to scope the alert to"},"environments":{"type":"array","items":{"type":"string"},"description":"Deployment environments to scope the alert to (e.g. 'production'). Omit for all environments. Ignored for builder_query / raw_query, which filter inside their own query."},"enabled":{"type":"boolean","description":"Whether the rule evaluates (default true)"},"signal_type":{"type":"string","enum":["error_rate","p95_latency","p99_latency","apdex","throughput","builder_query","raw_query"],"description":"What the rule measures. builder_query takes query_builder_draft, raw_query takes raw_query_sql. All other signals are computed over ROOT spans only: a service that fails on child spans but returns success from its entry point (cron jobs, workers; audit_setup STAT-04 lists them) reads healthy at any threshold. Use raw_query there or rely on error-issue notifications."},"comparator":{"type":"string","enum":["gt","gte","lt","lte","eq","neq"],"description":"How the observed value is compared with threshold (breach when `value threshold`). Ignored when a template is set."},"group_by":{"type":"array","items":{"type":"string"},"description":"Evaluate one value per group. Built-in tokens: service.name, span.name, status.code, http.method, severity; attribute keys as attr. (e.g. attr.http.route)."},"minimum_sample_count":{"type":"number","description":"Skip evaluation below this many samples in the window (default: 0). For raw_query it sums the `samples` column; without one each returned row counts as 1, so it gates on buckets, not events."},"alert_on_no_data":{"type":"boolean","description":"Count a window with no data as a breach, so a rule whose query stops matching opens an incident instead of going quiet (default false: such windows are skipped). Not supported with group_by."},"consecutive_breaches":{"type":"number","description":"Consecutive breaches before alerting (default: 2)"},"consecutive_healthy":{"type":"number","description":"Consecutive healthy evaluations before resolving (default: 2)"},"renotify_interval_minutes":{"type":"number","description":"Re-notification interval in minutes (default: 30)"},"apdex_threshold_ms":{"type":"number","description":"Response time counted as satisfactory, in ms. Required for signal_type=apdex."},"query_builder_draft":{"type":"string","contentMediaType":"application/json","contentSchema":{"anyOf":[{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["traces"]},"valueField":{"type":"string"}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["logs"]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["metrics"]},"signalSource":{"type":"string","enum":["default","meter"]},"metricName":{"type":"string"},"metricType":{"type":"string","enum":["sum","gauge","histogram","exponential_histogram"]},"isMonotonic":{"type":"boolean"}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["product_events"]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false}]},"description":"The query to evaluate, in the query-builder draft shape dashboard custom-query widgets use ({ id, name, dataSource, aggregation, whereClause, groupBy, ... }). Required for signal_type=builder_query."},"raw_query_sql":{"type":"string","description":"ClickHouse SQL returning a numeric `value` column and optional `group` / `samples` columns (`samples` is the event count behind each row; it feeds minimum_sample_count). A query returning no rows is a no-data check, not a healthy one. Must reference $__orgFilter and $__timeFilter(col); $__startTime, $__endTime and $__interval_s are also available. At most 100 distinct groups. Required for signal_type=raw_query."},"raw_query_reducer":{"type":"string","enum":["identity","sum","avg","max","min"],"description":"How several raw_query rows collapse into one value (default identity: the single row's value)."},"notification_title":{"type":"string","description":"Custom notification title template. Supports {{ variable }} substitution, e.g. \"{{ event.emoji }} {{ rule.name }}: {{ event.label }}\". Omit for the built-in format. Variables: rule.name, severity, signal.label, comparator.label, threshold, value, observed.summary, group, window, links.app, links.chat."},"notification_body":{"type":"string","description":"Custom notification body template (Markdown). Supports {{ variable }} substitution and {{#if key}}\u2026{{/if}} blocks, e.g. \"*Severity:* {{ severity }}\\n*Observed:* {{ observed.summary }}\". Omit for the built-in format."}},"required":["name","destination_ids"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"rule":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"severity":{"type":"string"},"serviceNames":{"type":"array","items":{"type":"string"}},"environments":{"type":"array","items":{"type":"string"}},"signalType":{"type":"string"},"comparator":{"type":"string"},"threshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"evaluationIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"slowedIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"destinationIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","enabled","severity","serviceNames","environments","signalType","comparator","threshold","windowMinutes","evaluationIntervalMinutes","slowedIntervalMinutes","destinationIds","createdAt","updatedAt"],"additionalProperties":true},"warnings":{"type":"array","items":{"type":"string"}},"evaluation":{"type":"object","properties":{"nextEvaluationAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"current":{"type":"array","items":{"type":"object","properties":{"groupKey":{"type":"string"},"window":{"type":"string"},"status":{"type":"string"},"skipReason":{"anyOf":[{"type":"string"},{"type":"null"}]},"value":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"sampleCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["groupKey","window","status","skipReason","value","sampleCount"],"additionalProperties":true}},"previewError":{"type":"string"}},"required":["nextEvaluationAt","current"],"additionalProperties":true}},"required":["rule"],"additionalProperties":true},"annotations":{"title":"Create Alert Rule","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, {"name":"update_alert_rule","title":"Update Alert Rule","description":"Update an alert rule. Pass only the fields to change; the rest keep their current value (get_alert_rule shows it). rule_ids applies one change to many rules (e.g. add_destination_ids on every rule with rule_ids=[\"*\"]). Ids from list_alert_rules and list_alert_destinations.","inputSchema":{"type":"object","properties":{"rule_id":{"type":"string","description":"Alert rule ID to update (use list_alert_rules to find IDs): its UUID or its REST id (alrt_\u2026)"},"rule_ids":{"type":"array","items":{"type":"string"},"description":"Apply the same change to several rules in one call instead of rule_id; [\"*\"] means every rule. name cannot be set in bulk. Each is its UUID or its REST id (alrt_\u2026)"},"name":{"type":"string","description":"New rule name"},"severity":{"type":"string","enum":["warning","critical"],"description":"Alert severity"},"threshold":{"type":"number","description":"Threshold value. E.g. 0.05 for 5% error rate, 1000 for 1s latency"},"window_minutes":{"type":"number","description":"Evaluation window in minutes, at most 1440"},"evaluation_interval_minutes":{"anyOf":[{"type":"number","enum":[1,5,10,15,30,60]},{"type":"string"},{"type":"string","enum":["auto"]}],"description":"Minutes between checks, at most window_minutes. A raw_query rule checked less often gets a longer query time limit: 5s under 5 min, 15s at 5, 25s at 10 or more. Omit to keep the current one; pass \"auto\" to derive it from the window (a fifth, 1 to 5). Any edit also clears a slowdown the scheduler applied."},"services":{"type":"array","items":{"type":"string"},"description":"Service scope (replaces the current one; an empty list means all services)"},"environments":{"type":"array","items":{"type":"string"},"description":"Deployment environments to scope the alert to (replaces the current scope; pass an empty list for all environments). Ignored for builder_query / raw_query."},"enabled":{"type":"boolean","description":"Whether the rule is enabled"},"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destination IDs to notify, from list_alert_destinations; replaces the current ones. A rule needs at least one. Each is its UUID or its REST id (dest_\u2026)"},"add_destination_ids":{"type":"array","items":{"type":"string"},"description":"Destination IDs to add, keeping the current ones. Each is its UUID or its REST id (dest_\u2026)"},"remove_destination_ids":{"type":"array","items":{"type":"string"},"description":"Destination IDs to remove, keeping the rest. Each is its UUID or its REST id (dest_\u2026)"},"signal_type":{"type":"string","enum":["error_rate","p95_latency","p99_latency","apdex","throughput","builder_query","raw_query"],"description":"What the rule measures. builder_query takes query_builder_draft, raw_query takes raw_query_sql."},"comparator":{"type":"string","enum":["gt","gte","lt","lte","eq","neq"],"description":"How the observed value is compared with threshold"},"group_by":{"type":"array","items":{"type":"string"},"description":"Dimensions to evaluate the alert per-group (replaces the current grouping; an empty list removes it). Built-in tokens: service.name, span.name, status.code, http.method, severity. Attribute keys: attr.."},"minimum_sample_count":{"type":"number","description":"Skip evaluation below this many samples in the window. For raw_query it sums the `samples` column; without one each returned row counts as 1."},"alert_on_no_data":{"type":"boolean","description":"Count a window with no data as a breach instead of skipping it. Not supported with group_by."},"consecutive_breaches":{"type":"number","description":"Consecutive breaches before alerting"},"consecutive_healthy":{"type":"number","description":"Consecutive healthy evaluations before resolving"},"renotify_interval_minutes":{"type":"number","description":"Re-notification interval in minutes"},"apdex_threshold_ms":{"type":"number","description":"Apdex threshold in milliseconds (for signal_type=apdex)"},"query_builder_draft":{"type":"string","contentMediaType":"application/json","contentSchema":{"anyOf":[{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["traces"]},"valueField":{"type":"string"}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["logs"]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["metrics"]},"signalSource":{"type":"string","enum":["default","meter"]},"metricName":{"type":"string"},"metricType":{"type":"string","enum":["sum","gauge","histogram","exponential_histogram"]},"isMonotonic":{"type":"boolean"}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["product_events"]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false}]},"description":"Query-builder draft, same shape as create_alert_rule (for signal_type=builder_query)"},"raw_query_sql":{"type":"string","description":"ClickHouse SQL returning a numeric `value` column (for signal_type=raw_query), at most 100 groups. Must reference $__orgFilter and $__timeFilter(col)."},"raw_query_reducer":{"type":"string","enum":["identity","sum","avg","max","min"],"description":"How to collapse raw_query result rows into one value."},"notification_title":{"type":"string","description":"Notification title template with {{ variable }} substitution; variables as in create_alert_rule"},"notification_body":{"type":"string","description":"Notification body template (Markdown) with {{ variable }} and {{#if key}}...{{/if}}"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"rule":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"severity":{"type":"string"},"serviceNames":{"type":"array","items":{"type":"string"}},"environments":{"type":"array","items":{"type":"string"}},"signalType":{"type":"string"},"comparator":{"type":"string"},"threshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"evaluationIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"slowedIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"destinationIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","enabled","severity","serviceNames","environments","signalType","comparator","threshold","windowMinutes","evaluationIntervalMinutes","slowedIntervalMinutes","destinationIds","createdAt","updatedAt"],"additionalProperties":true},"rules":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"severity":{"type":"string"},"serviceNames":{"type":"array","items":{"type":"string"}},"environments":{"type":"array","items":{"type":"string"}},"signalType":{"type":"string"},"comparator":{"type":"string"},"threshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"evaluationIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"slowedIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"destinationIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","enabled","severity","serviceNames","environments","signalType","comparator","threshold","windowMinutes","evaluationIntervalMinutes","slowedIntervalMinutes","destinationIds","createdAt","updatedAt"],"additionalProperties":true}},"warnings":{"type":"array","items":{"type":"string"}},"evaluation":{"type":"object","properties":{"nextEvaluationAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"current":{"type":"array","items":{"type":"object","properties":{"groupKey":{"type":"string"},"window":{"type":"string"},"status":{"type":"string"},"skipReason":{"anyOf":[{"type":"string"},{"type":"null"}]},"value":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"sampleCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["groupKey","window","status","skipReason","value","sampleCount"],"additionalProperties":true}},"previewError":{"type":"string"}},"required":["nextEvaluationAt","current"],"additionalProperties":true}},"required":["rule"],"additionalProperties":true},"annotations":{"title":"Update Alert Rule","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"delete_alert_rule","title":"Delete Alert Rule","description":"Permanently delete an alert rule together with its incident history, delivery events and evaluation state. Irreversible; requires confirm=true. Ids from list_alert_rules.","inputSchema":{"type":"object","properties":{"rule_id":{"type":"string","description":"Alert rule ID: its UUID or its REST id (alrt_\u2026)"},"confirm":{"type":"boolean","description":"Must be true. There is no undo."}},"required":["rule_id","confirm"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"],"additionalProperties":true},"annotations":{"title":"Delete Alert Rule","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}}, + {"name":"list_alert_silences","title":"List Alert Silences","description":"List alert silences: windows that hold back alert notifications for matching rules (incidents still open and resolve). Defaults to active and scheduled silences; pass status=ended for past ones.","inputSchema":{"type":"object","properties":{"status":{"type":"string","enum":["scheduled","active","ended"],"description":"Only silences in this status (default: active and scheduled)"},"limit":{"type":"number","description":"Max silences to return (default 50, max 200)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"silences":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"reason":{"type":"string"},"matchers":{"type":"object","properties":{"ruleIds":{"type":"array","items":{"type":"string"}},"tags":{"type":"array","items":{"type":"string"}},"services":{"type":"array","items":{"type":"string"}},"environments":{"type":"array","items":{"type":"string"}}},"additionalProperties":true},"status":{"type":"string"},"startsAt":{"type":"string"},"endsAt":{"type":"string"},"createdBy":{"type":"string"}},"required":["id","reason","matchers","status","startsAt","endsAt","createdBy"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"status":{"type":"string"},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"hasMore":{"type":"boolean"}},"required":["silences","total","limit","hasMore"],"additionalProperties":true},"annotations":{"title":"List Alert Silences","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"create_alert_silence","title":"Create Alert Silence","description":"Hold back alert notifications for matching rules during a window (maintenance, a known outage). Incidents still open and resolve; only delivery is held. Matchers AND together, values inside one OR; pass at least one. services/environments must cover a rule's whole scope or the incident's group. Pass duration_minutes or ends_at (max 30 days).","inputSchema":{"type":"object","properties":{"reason":{"type":"string","description":"Why notifications are held back; shown on the silence and on held-back incidents"},"rule_ids":{"type":"array","items":{"type":"string"},"description":"Silence these alert rules (ids from list_alert_rules). Each is its UUID or its REST id (alrt_\u2026)"},"tags":{"type":"array","items":{"type":"string"},"description":"Silence rules carrying any of these tags"},"services":{"type":"array","items":{"type":"string"},"description":"Silence rules and incident groups covered by these services"},"environments":{"type":"array","items":{"type":"string"},"description":"Silence rules and incident groups covered by these environments"},"duration_minutes":{"type":"number","description":"How long the silence lasts from its start, in minutes"},"ends_at":{"type":"string","description":"When the silence ends, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601)"},"starts_at":{"type":"string","description":"When the silence starts (default: now), UTC (YYYY-MM-DD HH:mm:ss or ISO 8601)"}},"required":["reason"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"silence":{"type":"object","properties":{"id":{"type":"string"},"reason":{"type":"string"},"matchers":{"type":"object","properties":{"ruleIds":{"type":"array","items":{"type":"string"}},"tags":{"type":"array","items":{"type":"string"}},"services":{"type":"array","items":{"type":"string"}},"environments":{"type":"array","items":{"type":"string"}}},"additionalProperties":true},"status":{"type":"string"},"startsAt":{"type":"string"},"endsAt":{"type":"string"},"createdBy":{"type":"string"}},"required":["id","reason","matchers","status","startsAt","endsAt","createdBy"],"additionalProperties":true}},"required":["silence"],"additionalProperties":true},"annotations":{"title":"Create Alert Silence","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, + {"name":"end_alert_silence","title":"End Alert Silence","description":"End an alert silence now so notifications resume; a scheduled one is cancelled. Ids from list_alert_silences.","inputSchema":{"type":"object","properties":{"silence_id":{"type":"string","description":"Silence to end: its UUID or its REST id (sil_\u2026)"}},"required":["silence_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"silence":{"type":"object","properties":{"id":{"type":"string"},"reason":{"type":"string"},"matchers":{"type":"object","properties":{"ruleIds":{"type":"array","items":{"type":"string"}},"tags":{"type":"array","items":{"type":"string"}},"services":{"type":"array","items":{"type":"string"}},"environments":{"type":"array","items":{"type":"string"}}},"additionalProperties":true},"status":{"type":"string"},"startsAt":{"type":"string"},"endsAt":{"type":"string"},"createdBy":{"type":"string"}},"required":["id","reason","matchers","status","startsAt","endsAt","createdBy"],"additionalProperties":true}},"required":["silence"],"additionalProperties":true},"annotations":{"title":"End Alert Silence","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"describe_dashboard_schema","title":"Describe Dashboard Schema","description":"What a dashboard widget can be: panel types, the four data-source kinds, the unit vocabulary, aggregations and group-by tokens, the display config, and (section templates) the create_dashboard templates. Generated from the live schema; read it before authoring or editing widgets rather than working from a remembered example.","inputSchema":{"type":"object","properties":{"section":{"type":"string","enum":["panel_types","data_sources","units","queries","display","raw_sql","templates"],"description":"Omit for the index plus the panel-type table."}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"section":{"type":"string"},"markdown":{"type":"string"}},"required":["markdown"],"additionalProperties":true},"annotations":{"title":"Describe Dashboard Schema","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"list_dashboards","title":"List Dashboards","description":"List all dashboards with widget counts and timestamps. Use get_dashboard to see full widget configuration.","inputSchema":{"type":"object","properties":{"search":{"type":"string","description":"Filter dashboards by name (case-insensitive contains)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"dashboards":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}},"widgetCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","widgetCount","createdAt","updatedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"search":{"type":"string"}},"required":["dashboards","total"],"additionalProperties":true},"annotations":{"title":"List Dashboards","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"get_dashboard","title":"Get Dashboard","description":"Full configuration of one dashboard: every widget with its id, visualization, dataSource, display, layout and any pinned timeRange. Widgets come back in the shape update_dashboard_widget and replace_dashboard_widgets take. Ids from list_dashboards.","inputSchema":{"type":"object","properties":{"dashboard_id":{"type":"string","description":"Dashboard ID: its UUID or its REST id (dash_\u2026)"}},"required":["dashboard_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"publicId":{"type":"string"},"dashboard":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}},"timeRange":{"anyOf":[{"type":"object","properties":{"type":{"type":"string","enum":["relative"]},"value":{"type":"string"}},"required":["type","value"],"additionalProperties":true},{"type":"object","properties":{"type":{"type":"string","enum":["absolute"]},"startTime":{"$ref":"#/$defs/@maple~1IsoDateTimeString"},"endTime":{"$ref":"#/$defs/@maple~1IsoDateTimeString"}},"required":["type","startTime","endTime"],"additionalProperties":true}]},"widgets":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"visualization":{"type":"string","enum":["chart","hbar","stat","gauge","table","list","pie","histogram","heatmap","funnel","paths","markdown"]},"dataSource":{"$ref":"#/$defs/@maple~1WidgetDataSource"},"display":{"type":"object","properties":{"title":{"anyOf":[{"type":"string"},{"type":"null"}]},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"chartId":{"anyOf":[{"type":"string"},{"type":"null"}]},"chartPresentation":{"anyOf":[{"type":"object","properties":{"legend":{"anyOf":[{"type":"string","enum":["visible","hidden","right"]},{"type":"null"}]},"seriesStats":{"anyOf":[{"type":"boolean"},{"type":"null"}]},"tooltip":{"anyOf":[{"type":"string","enum":["visible","hidden"]},{"type":"null"}]},"showPoints":{"anyOf":[{"type":"boolean"},{"type":"null"}]},"fillNulls":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"boolean","enum":[false]},{"type":"null"}]},"compareToPreviousPeriod":{"anyOf":[{"type":"boolean"},{"type":"null"}]}},"additionalProperties":true},{"type":"null"}]},"xAxis":{"anyOf":[{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"null"}]},"unit":{"anyOf":[{"type":"string"},{"type":"null"}]},"visible":{"anyOf":[{"type":"boolean"},{"type":"null"}]}},"additionalProperties":true},{"type":"null"}]},"yAxis":{"anyOf":[{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"null"}]},"unit":{"anyOf":[{"type":"string"},{"type":"null"}]},"min":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"max":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"softMin":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"softMax":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"logScale":{"anyOf":[{"type":"boolean"},{"type":"null"}]},"fitYAxisToData":{"anyOf":[{"type":"boolean"},{"type":"null"}]},"visible":{"anyOf":[{"type":"boolean"},{"type":"null"}]}},"additionalProperties":true},{"type":"null"}]},"seriesMapping":{"anyOf":[{"type":"object","additionalProperties":{"type":"string"}},{"type":"null"}]},"colorOverrides":{"anyOf":[{"type":"object","additionalProperties":{"type":"string"}},{"type":"null"}]},"stacked":{"anyOf":[{"type":"boolean"},{"type":"null"}]},"curveType":{"anyOf":[{"type":"string","enum":["linear","monotone"]},{"type":"null"}]},"unit":{"anyOf":[{"type":"string"},{"type":"null"}]},"thresholds":{"anyOf":[{"type":"array","items":{"type":"object","properties":{"value":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"color":{"type":"string"},"label":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["value","color"],"additionalProperties":true}},{"type":"null"}]},"prefix":{"anyOf":[{"type":"string"},{"type":"null"}]},"suffix":{"anyOf":[{"type":"string"},{"type":"null"}]},"sparkline":{"anyOf":[{"type":"object","properties":{"enabled":{"type":"boolean"},"dataSource":{"anyOf":[{"$ref":"#/$defs/@maple~1WidgetDataSource"},{"type":"null"}]}},"required":["enabled"],"additionalProperties":true},{"type":"null"}]},"columns":{"anyOf":[{"type":"array","items":{"type":"object","properties":{"field":{"type":"string"},"header":{"type":"string"},"unit":{"anyOf":[{"type":"string"},{"type":"null"}]},"width":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"align":{"anyOf":[{"type":"string","enum":["left","center","right"]},{"type":"null"}]},"hidden":{"anyOf":[{"type":"boolean"},{"type":"null"}]},"thresholds":{"anyOf":[{"type":"array","items":{"type":"object","properties":{"value":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"color":{"type":"string"}},"required":["value","color"],"additionalProperties":true}},{"type":"null"}]}},"required":["field","header"],"additionalProperties":true}},{"type":"null"}]},"listDataSource":{"anyOf":[{"type":"string"},{"type":"null"}]},"listWhereClause":{"anyOf":[{"type":"string"},{"type":"null"}]},"listLimit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"listRootOnly":{"anyOf":[{"type":"boolean"},{"type":"null"}]},"pie":{"anyOf":[{"type":"object","properties":{"donut":{"anyOf":[{"type":"boolean"},{"type":"null"}]},"innerRadius":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"showLabels":{"anyOf":[{"type":"boolean"},{"type":"null"}]},"showPercent":{"anyOf":[{"type":"boolean"},{"type":"null"}]}},"additionalProperties":true},{"type":"null"}]},"funnel":{"anyOf":[{"type":"object","properties":{"showStepPercent":{"anyOf":[{"type":"boolean"},{"type":"null"}]},"steps":{"anyOf":[{"type":"array","items":{"anyOf":[{"type":"object","properties":{"kind":{"type":"string","enum":["event"]},"eventName":{"type":"string"},"attributeEquals":{"type":"object","additionalProperties":{"type":"string"}}},"required":["kind","eventName"],"additionalProperties":true},{"type":"object","properties":{"kind":{"type":"string","enum":["page"]},"pagePath":{"type":"string"},"host":{"type":"string"}},"required":["kind","pagePath"],"additionalProperties":true},{"type":"object","properties":{"kind":{"type":"string","enum":["session"]},"dimension":{"type":"string","enum":["referrerHost","utmSource","utmMedium","utmCampaign","country","host"]},"value":{"type":"string"}},"required":["kind","dimension","value"],"additionalProperties":true}]}},{"type":"null"}]},"keyBy":{"anyOf":[{"type":"string","enum":["person","visitor","user","session"]},{"type":"null"}]},"windowSeconds":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"breakdownBy":{"anyOf":[{"type":"string","enum":["referrerHost","utmSource","utmMedium","utmCampaign","country","host"]},{"type":"string","pattern":"^attribute:[\\s\\S]*?$"},{"type":"null"}]},"filters":{"anyOf":[{"type":"object","properties":{"host":{"anyOf":[{"type":"string"},{"type":"null"}]},"pagePath":{"anyOf":[{"type":"string"},{"type":"null"}]},"referrerHost":{"anyOf":[{"type":"string"},{"type":"null"}]},"country":{"anyOf":[{"type":"string"},{"type":"null"}]},"deviceType":{"anyOf":[{"type":"string"},{"type":"null"}]},"browserName":{"anyOf":[{"type":"string"},{"type":"null"}]},"osName":{"anyOf":[{"type":"string"},{"type":"null"}]},"language":{"anyOf":[{"type":"string"},{"type":"null"}]},"utmSource":{"anyOf":[{"type":"string"},{"type":"null"}]},"utmMedium":{"anyOf":[{"type":"string"},{"type":"null"}]},"utmCampaign":{"anyOf":[{"type":"string"},{"type":"null"}]},"visitorType":{"anyOf":[{"type":"string","enum":["new","returning"]},{"type":"null"}]}},"additionalProperties":true},{"type":"null"}]},"variant":{"anyOf":[{"type":"string","enum":["bars","dropoff"]},{"type":"null"}]}},"additionalProperties":true},{"type":"null"}]},"paths":{"anyOf":[{"type":"object","properties":{"anchor":{"anyOf":[{"type":"object","properties":{"kind":{"type":"string","enum":["event"]},"eventName":{"type":"string"},"attributeEquals":{"type":"object","additionalProperties":{"type":"string"}}},"required":["kind","eventName"],"additionalProperties":true},{"type":"object","properties":{"kind":{"type":"string","enum":["page"]},"pagePath":{"type":"string"},"host":{"type":"string"}},"required":["kind","pagePath"],"additionalProperties":true}]},"direction":{"anyOf":[{"type":"string","enum":["after","before"]},{"type":"null"}]},"depth":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"branches":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"keyBy":{"anyOf":[{"type":"string","enum":["person","visitor","user","session"]},{"type":"null"}]},"windowSeconds":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"include":{"anyOf":[{"type":"string","enum":["all","events","pages"]},{"type":"null"}]},"exclude":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]},"filters":{"anyOf":[{"type":"object","properties":{"host":{"anyOf":[{"type":"string"},{"type":"null"}]},"pagePath":{"anyOf":[{"type":"string"},{"type":"null"}]},"referrerHost":{"anyOf":[{"type":"string"},{"type":"null"}]},"country":{"anyOf":[{"type":"string"},{"type":"null"}]},"deviceType":{"anyOf":[{"type":"string"},{"type":"null"}]},"browserName":{"anyOf":[{"type":"string"},{"type":"null"}]},"osName":{"anyOf":[{"type":"string"},{"type":"null"}]},"language":{"anyOf":[{"type":"string"},{"type":"null"}]},"utmSource":{"anyOf":[{"type":"string"},{"type":"null"}]},"utmMedium":{"anyOf":[{"type":"string"},{"type":"null"}]},"utmCampaign":{"anyOf":[{"type":"string"},{"type":"null"}]},"visitorType":{"anyOf":[{"type":"string","enum":["new","returning"]},{"type":"null"}]}},"additionalProperties":true},{"type":"null"}]}},"required":["anchor"],"additionalProperties":true},{"type":"null"}]},"histogram":{"anyOf":[{"type":"object","properties":{"bucketCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"bucketWidth":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"logScaleY":{"anyOf":[{"type":"boolean"},{"type":"null"}]}},"additionalProperties":true},{"type":"null"}]},"heatmap":{"anyOf":[{"type":"object","properties":{"colorScale":{"anyOf":[{"type":"string","enum":["amber","blues","reds","viridis","magma","cividis"]},{"type":"null"}]},"scaleType":{"anyOf":[{"type":"string","enum":["linear","log"]},{"type":"null"}]}},"additionalProperties":true},{"type":"null"}]},"gauge":{"anyOf":[{"type":"object","properties":{"min":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"max":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"style":{"anyOf":[{"type":"string","enum":["radial","bar"]},{"type":"null"}]}},"additionalProperties":true},{"type":"null"}]},"markdown":{"anyOf":[{"type":"object","properties":{"content":{"type":"string"}},"required":["content"],"additionalProperties":true},{"type":"null"}]}},"additionalProperties":true},"layout":{"type":"object","properties":{"x":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"y":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"w":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"h":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"minW":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"minH":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"maxW":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"maxH":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]}},"required":["x","y","w","h"],"additionalProperties":true},"timeRange":{"anyOf":[{"type":"object","properties":{"type":{"type":"string","enum":["relative"]},"value":{"type":"string"}},"required":["type","value"],"additionalProperties":true},{"type":"object","properties":{"type":{"type":"string","enum":["absolute"]},"startTime":{"$ref":"#/$defs/@maple~1IsoDateTimeString"},"endTime":{"$ref":"#/$defs/@maple~1IsoDateTimeString"}},"required":["type","startTime","endTime"],"additionalProperties":true}]},"sectionId":{"type":"string"},"tabId":{"type":"string"}},"required":["id","visualization","dataSource","display","layout"],"additionalProperties":true}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","tags","timeRange","widgets","createdAt","updatedAt"],"additionalProperties":true}},"required":["publicId","dashboard"],"additionalProperties":true,"$defs":{"@maple/IsoDateTimeString":{"type":"string"},"@maple/QueryWidgetDataSource":{"type":"object","properties":{"kind":{"type":"string","enum":["query"]},"resultShape":{"type":"string","enum":["timeseries","breakdown","list"]},"queries":{"type":"array","items":{"anyOf":[{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"anyOf":[{"type":"boolean"},{"type":"null"}]},"hidden":{"anyOf":[{"type":"boolean"},{"type":"null"}]},"whereClause":{"anyOf":[{"type":"string"},{"type":"null"}]},"aggregation":{"type":"string"},"stepInterval":{"anyOf":[{"type":"string"},{"type":"null"}]},"orderByDirection":{"anyOf":[{"type":"string","enum":["desc","asc"]},{"type":"null"}]},"addOns":{"anyOf":[{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":true},{"type":"null"}]},"groupBy":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]},"having":{"anyOf":[{"type":"string"},{"type":"null"}]},"orderBy":{"anyOf":[{"type":"string"},{"type":"null"}]},"limit":{"anyOf":[{"type":"string"},{"type":"null"}]},"seriesLimit":{"anyOf":[{"type":"string"},{"type":"null"}]},"legend":{"anyOf":[{"type":"string"},{"type":"null"}]},"dataSource":{"type":"string","enum":["traces"]},"valueField":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":true},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"anyOf":[{"type":"boolean"},{"type":"null"}]},"hidden":{"anyOf":[{"type":"boolean"},{"type":"null"}]},"whereClause":{"anyOf":[{"type":"string"},{"type":"null"}]},"aggregation":{"type":"string"},"stepInterval":{"anyOf":[{"type":"string"},{"type":"null"}]},"orderByDirection":{"anyOf":[{"type":"string","enum":["desc","asc"]},{"type":"null"}]},"addOns":{"anyOf":[{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":true},{"type":"null"}]},"groupBy":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]},"having":{"anyOf":[{"type":"string"},{"type":"null"}]},"orderBy":{"anyOf":[{"type":"string"},{"type":"null"}]},"limit":{"anyOf":[{"type":"string"},{"type":"null"}]},"seriesLimit":{"anyOf":[{"type":"string"},{"type":"null"}]},"legend":{"anyOf":[{"type":"string"},{"type":"null"}]},"dataSource":{"type":"string","enum":["logs"]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":true},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"anyOf":[{"type":"boolean"},{"type":"null"}]},"hidden":{"anyOf":[{"type":"boolean"},{"type":"null"}]},"whereClause":{"anyOf":[{"type":"string"},{"type":"null"}]},"aggregation":{"type":"string"},"stepInterval":{"anyOf":[{"type":"string"},{"type":"null"}]},"orderByDirection":{"anyOf":[{"type":"string","enum":["desc","asc"]},{"type":"null"}]},"addOns":{"anyOf":[{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":true},{"type":"null"}]},"groupBy":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]},"having":{"anyOf":[{"type":"string"},{"type":"null"}]},"orderBy":{"anyOf":[{"type":"string"},{"type":"null"}]},"limit":{"anyOf":[{"type":"string"},{"type":"null"}]},"seriesLimit":{"anyOf":[{"type":"string"},{"type":"null"}]},"legend":{"anyOf":[{"type":"string"},{"type":"null"}]},"dataSource":{"type":"string","enum":["metrics"]},"signalSource":{"anyOf":[{"type":"string","enum":["default","meter"]},{"type":"null"}]},"metricName":{"anyOf":[{"type":"string"},{"type":"null"}]},"metricType":{"anyOf":[{"type":"string","enum":["sum","gauge","histogram","exponential_histogram"]},{"type":"null"}]},"isMonotonic":{"anyOf":[{"type":"boolean"},{"type":"null"}]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":true},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"anyOf":[{"type":"boolean"},{"type":"null"}]},"hidden":{"anyOf":[{"type":"boolean"},{"type":"null"}]},"whereClause":{"anyOf":[{"type":"string"},{"type":"null"}]},"aggregation":{"type":"string"},"stepInterval":{"anyOf":[{"type":"string"},{"type":"null"}]},"orderByDirection":{"anyOf":[{"type":"string","enum":["desc","asc"]},{"type":"null"}]},"addOns":{"anyOf":[{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":true},{"type":"null"}]},"groupBy":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]},"having":{"anyOf":[{"type":"string"},{"type":"null"}]},"orderBy":{"anyOf":[{"type":"string"},{"type":"null"}]},"limit":{"anyOf":[{"type":"string"},{"type":"null"}]},"seriesLimit":{"anyOf":[{"type":"string"},{"type":"null"}]},"legend":{"anyOf":[{"type":"string"},{"type":"null"}]},"dataSource":{"type":"string","enum":["product_events"]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":true}]}},"formulas":{"anyOf":[{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"expression":{"type":"string"},"legend":{"type":"string"},"hidden":{"anyOf":[{"type":"boolean"},{"type":"null"}]}},"required":["id","name","expression","legend"],"additionalProperties":true}},{"type":"null"}]},"comparison":{"anyOf":[{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","enum":["none","previous_period"]},{"type":"null"}]},"includePercentChange":{"anyOf":[{"type":"boolean"},{"type":"null"}]}},"additionalProperties":true},{"type":"null"}]},"defaultLimit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"columns":{"type":"array","items":{"type":"string"}},"transform":{"type":"object","properties":{"fieldMap":{"anyOf":[{"type":"object","additionalProperties":{"type":"string"}},{"type":"null"}]},"hideSeries":{"anyOf":[{"type":"object","properties":{"baseNames":{"type":"array","items":{"type":"string"}}},"required":["baseNames"],"additionalProperties":true},{"type":"null"}]},"flattenSeries":{"anyOf":[{"type":"object","properties":{"valueField":{"type":"string"}},"required":["valueField"],"additionalProperties":true},{"type":"null"}]},"reduceToValue":{"anyOf":[{"type":"object","properties":{"field":{"type":"string"},"aggregate":{"anyOf":[{"type":"string","enum":["first","sum","count","avg","max","min"]},{"type":"null"}]}},"required":["field"],"additionalProperties":true},{"type":"null"}]},"computeRatio":{"anyOf":[{"type":"object","properties":{"numeratorName":{"type":"string"},"denominatorNames":{"type":"array","items":{"type":"string"}}},"required":["numeratorName","denominatorNames"],"additionalProperties":true},{"type":"null"}]},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"sortBy":{"anyOf":[{"type":"object","properties":{"field":{"type":"string"},"direction":{"type":"string","enum":["asc","desc"]}},"required":["field","direction"],"additionalProperties":true},{"type":"null"}]}},"additionalProperties":true}},"required":["kind","resultShape","queries"],"additionalProperties":true,"title":"Query widget data source"},"@maple/RawSqlWidgetDataSource":{"type":"object","properties":{"kind":{"type":"string","enum":["raw_sql"]},"sql":{"type":"string"},"displayType":{"type":"string"},"granularitySeconds":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"transform":{"type":"object","properties":{"fieldMap":{"anyOf":[{"type":"object","additionalProperties":{"type":"string"}},{"type":"null"}]},"hideSeries":{"anyOf":[{"type":"object","properties":{"baseNames":{"type":"array","items":{"type":"string"}}},"required":["baseNames"],"additionalProperties":true},{"type":"null"}]},"flattenSeries":{"anyOf":[{"type":"object","properties":{"valueField":{"type":"string"}},"required":["valueField"],"additionalProperties":true},{"type":"null"}]},"reduceToValue":{"anyOf":[{"type":"object","properties":{"field":{"type":"string"},"aggregate":{"anyOf":[{"type":"string","enum":["first","sum","count","avg","max","min"]},{"type":"null"}]}},"required":["field"],"additionalProperties":true},{"type":"null"}]},"computeRatio":{"anyOf":[{"type":"object","properties":{"numeratorName":{"type":"string"},"denominatorNames":{"type":"array","items":{"type":"string"}}},"required":["numeratorName","denominatorNames"],"additionalProperties":true},{"type":"null"}]},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"sortBy":{"anyOf":[{"type":"object","properties":{"field":{"type":"string"},"direction":{"type":"string","enum":["asc","desc"]}},"required":["field","direction"],"additionalProperties":true},{"type":"null"}]}},"additionalProperties":true}},"required":["kind","sql"],"additionalProperties":true,"title":"Raw SQL widget data source"},"@maple/RouteWidgetDataSource":{"type":"object","properties":{"kind":{"type":"string","enum":["route"]},"endpoint":{"type":"string"},"params":{"type":"object"},"transform":{"type":"object","properties":{"fieldMap":{"anyOf":[{"type":"object","additionalProperties":{"type":"string"}},{"type":"null"}]},"hideSeries":{"anyOf":[{"type":"object","properties":{"baseNames":{"type":"array","items":{"type":"string"}}},"required":["baseNames"],"additionalProperties":true},{"type":"null"}]},"flattenSeries":{"anyOf":[{"type":"object","properties":{"valueField":{"type":"string"}},"required":["valueField"],"additionalProperties":true},{"type":"null"}]},"reduceToValue":{"anyOf":[{"type":"object","properties":{"field":{"type":"string"},"aggregate":{"anyOf":[{"type":"string","enum":["first","sum","count","avg","max","min"]},{"type":"null"}]}},"required":["field"],"additionalProperties":true},{"type":"null"}]},"computeRatio":{"anyOf":[{"type":"object","properties":{"numeratorName":{"type":"string"},"denominatorNames":{"type":"array","items":{"type":"string"}}},"required":["numeratorName","denominatorNames"],"additionalProperties":true},{"type":"null"}]},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"sortBy":{"anyOf":[{"type":"object","properties":{"field":{"type":"string"},"direction":{"type":"string","enum":["asc","desc"]}},"required":["field","direction"],"additionalProperties":true},{"type":"null"}]}},"additionalProperties":true}},"required":["kind","endpoint"],"additionalProperties":true,"title":"Route widget data source"},"@maple/StaticWidgetDataSource":{"type":"object","properties":{"kind":{"type":"string","enum":["static"]},"transform":{"type":"object","properties":{"fieldMap":{"anyOf":[{"type":"object","additionalProperties":{"type":"string"}},{"type":"null"}]},"hideSeries":{"anyOf":[{"type":"object","properties":{"baseNames":{"type":"array","items":{"type":"string"}}},"required":["baseNames"],"additionalProperties":true},{"type":"null"}]},"flattenSeries":{"anyOf":[{"type":"object","properties":{"valueField":{"type":"string"}},"required":["valueField"],"additionalProperties":true},{"type":"null"}]},"reduceToValue":{"anyOf":[{"type":"object","properties":{"field":{"type":"string"},"aggregate":{"anyOf":[{"type":"string","enum":["first","sum","count","avg","max","min"]},{"type":"null"}]}},"required":["field"],"additionalProperties":true},{"type":"null"}]},"computeRatio":{"anyOf":[{"type":"object","properties":{"numeratorName":{"type":"string"},"denominatorNames":{"type":"array","items":{"type":"string"}}},"required":["numeratorName","denominatorNames"],"additionalProperties":true},{"type":"null"}]},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"sortBy":{"anyOf":[{"type":"object","properties":{"field":{"type":"string"},"direction":{"type":"string","enum":["asc","desc"]}},"required":["field","direction"],"additionalProperties":true},{"type":"null"}]}},"additionalProperties":true}},"required":["kind"],"additionalProperties":true,"title":"Static widget data source"},"@maple/WidgetDataSource":{"anyOf":[{"$ref":"#/$defs/@maple~1QueryWidgetDataSource"},{"$ref":"#/$defs/@maple~1RawSqlWidgetDataSource"},{"$ref":"#/$defs/@maple~1RouteWidgetDataSource"},{"$ref":"#/$defs/@maple~1StaticWidgetDataSource"}],"title":"Widget data source"}}},"annotations":{"title":"Get Dashboard","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, diff --git a/apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts new file mode 100644 index 0000000000..377e2dd716 --- /dev/null +++ b/apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts @@ -0,0 +1,259 @@ +/** + * The alert silence tools through the registry: matcher and window validation as invalid input, + * the request the service receives, the default status filter, and silences on the timeline. + */ +import { describe, expect, it } from "vitest" +import { Context, Effect, Layer, Schema, Array as Arr } from "effect" +import { + AlertIncidentsListResponse, + AlertSilenceCreateRequest, + AlertSilenceDocument, + AlertSilenceNotFoundError, + AlertSilencesListResponse, + OrgId, + UserId, +} from "@maple/domain/http" +import { GetIncidentTimelineOutput, ListAlertSilencesOutput } from "@maple/domain/mcp-outputs" +import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" +import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" +import { CurrentMcpTenant } from "../../lib/query-warehouse" +import { executeRegisteredMcpToolUnscoped } from "../registry" +import type { McpToolResult } from "../types" + +const RULE_ID = "7f1c2d3e-4b5a-4c6d-8e9f-0a1b2c3d4e5f" +const ACTIVE_ID = "a1111111-2222-4333-8444-555555555555" +const SCHEDULED_ID = "b1111111-2222-4333-8444-555555555555" +const ENDED_ID = "c1111111-2222-4333-8444-555555555555" +const INCIDENT_ID = "11111111-2222-4333-8444-555555555555" +const NOW = "2026-09-24T10:00:00.000Z" + +const silence = (id: string, status: "scheduled" | "active" | "ended", reason: string) => + Schema.decodeSync(AlertSilenceDocument)({ + id, + reason, + matchers: { services: ["checkout"] }, + status, + startsAt: NOW, + endsAt: "2026-09-24T12:00:00.000Z", + createdBy: "user_tools", + createdAt: NOW, + updatedAt: NOW, + }) + +const stored = [ + silence(ACTIVE_ID, "active", "Checkout deploy"), + silence(SCHEDULED_ID, "scheduled", "DB maintenance"), + silence(ENDED_ID, "ended", "Old outage"), +] + +interface Seen { + created?: AlertSilenceCreateRequest + listedStatuses: Array + ended?: string +} + +const layer = (seen: Seen) => + Layer.mergeAll( + Layer.succeed(CurrentMcpTenant, { + orgId: Schema.decodeSync(OrgId)("org_tools"), + userId: Schema.decodeSync(UserId)("user_tools"), + roles: [], + authMode: "self_hosted", + } as never), + Layer.succeed(AlertSilencesService, { + list: (_org: unknown, options: { readonly status?: string } = {}) => { + seen.listedStatuses.push(options.status) + return Effect.succeed( + new AlertSilencesListResponse({ + silences: Arr.filter( + stored, + (s) => options.status === undefined || s.status === options.status, + ), + }), + ) + }, + get: (_org: unknown, id: string) => { + const found = Arr.findFirst(stored, (s) => s.id === id) + return found._tag === "Some" + ? Effect.succeed(found.value) + : Effect.fail( + new AlertSilenceNotFoundError({ message: "not found", silenceId: id as never }), + ) + }, + create: (_org: unknown, _user: unknown, _roles: unknown, request: AlertSilenceCreateRequest) => { + seen.created = request + return Effect.succeed( + Schema.decodeSync(AlertSilenceDocument)({ + ...Schema.encodeSync(AlertSilenceDocument)(stored[0]!), + reason: request.reason, + matchers: request.matchers, + endsAt: request.endsAt, + }), + ) + }, + end: (_org: unknown, _user: unknown, _roles: unknown, id: string) => { + seen.ended = id + return id === ACTIVE_ID + ? Effect.succeed(silence(ACTIVE_ID, "ended", "Checkout deploy")) + : Effect.fail( + new AlertSilenceNotFoundError({ message: "not found", silenceId: id as never }), + ) + }, + } as never), + Layer.succeed(AlertReadModelsService, { + listIncidents: () => + Effect.succeed( + Schema.decodeSync(AlertIncidentsListResponse)({ + incidents: [ + { + id: INCIDENT_ID, + ruleId: RULE_ID, + ruleName: "Checkout errors", + groupKey: "checkout", + signalType: "error_rate", + severity: "critical", + status: "open", + comparator: "gt", + threshold: 0.05, + thresholdUpper: null, + peakSeverity: null, + firstTriggeredAt: NOW, + lastTriggeredAt: NOW, + resolvedAt: null, + lastObservedValue: 0.12, + lastSampleCount: 100, + dedupeKey: "k", + lastDeliveredEventType: null, + lastNotifiedAt: null, + holdReason: null, + heldSince: null, + errorIssueId: null, + silenceId: ACTIVE_ID, + silencedAt: NOW, + }, + ], + }), + ), + } as never), + ) + +// The registry's requirement union names every tool's services; these tools reach only the +// ones `layer` provides, so the rest are left absent (a defect if ever reached). +const withoutUnreachedServices = (effect: Effect.Effect): Effect.Effect => + Effect.provide(effect, Context.makeUnsafe(new Map())) + +const run = (name: string, params: unknown, seen: Seen) => + Effect.runPromise( + executeRegisteredMcpToolUnscoped(name, params, "mcp").pipe( + Effect.provide(layer(seen)), + Effect.result, + withoutUnreachedServices, + ), + ) + +const ok = async (name: string, params: unknown, seen: Seen) => { + const result = await run(name, params, seen) + if (result._tag !== "Success") throw new Error(`${name} failed: ${JSON.stringify(result.failure)}`) + return result.success +} + +const invalidParameter = (result: Awaited>) => + result._tag === "Failure" && "parameter" in result.failure ? result.failure.parameter : undefined + +const text = (result: McpToolResult) => result.content[0]?.text ?? "" +const fresh = (): Seen => ({ listedStatuses: [] }) + +describe("alert silence tools", () => { + it("create_alert_silence turns a duration into an end time and passes the matchers", async () => { + const seen = fresh() + const result = await ok( + "create_alert_silence", + { + reason: "Checkout deploy", + rule_ids: [RULE_ID], + services: "checkout", + tags: [], + duration_minutes: "90", + }, + seen, + ) + expect(seen.created?.matchers).toEqual({ ruleIds: [RULE_ID], services: ["checkout"] }) + expect(seen.created?.startsAt).toBeUndefined() + const endsInMs = Date.parse(seen.created?.endsAt ?? "") - Date.now() + expect(endsInMs).toBeGreaterThan(89 * 60_000) + expect(endsInMs).toBeLessThanOrEqual(90 * 60_000) + expect(text(result)).toContain("Reason: Checkout deploy") + expect(text(result)).toContain(`\`end_alert_silence silence_id="${ACTIVE_ID}"\``) + }) + + it("create_alert_silence ends a duration after an explicit start", async () => { + const seen = fresh() + await ok( + "create_alert_silence", + { + reason: "Window", + tags: ["payments"], + starts_at: "2030-01-01 00:00:00", + duration_minutes: 30, + }, + seen, + ) + expect(seen.created?.startsAt).toBe("2030-01-01T00:00:00.000Z") + expect(seen.created?.endsAt).toBe("2030-01-01T00:30:00.000Z") + }) + + it("create_alert_silence refuses a silence with no matcher", async () => { + const seen = fresh() + const result = await run( + "create_alert_silence", + { reason: "Everything", services: [], duration_minutes: 30 }, + seen, + ) + expect(invalidParameter(result)).toBe("rule_ids") + expect(seen.created).toBeUndefined() + }) + + it("create_alert_silence needs exactly one of duration_minutes and ends_at", async () => { + const seen = fresh() + const both = await run( + "create_alert_silence", + { reason: "x", tags: ["a"], duration_minutes: 30, ends_at: "2030-01-01T00:00:00Z" }, + seen, + ) + expect(invalidParameter(both)).toBe("duration_minutes") + const neither = await run("create_alert_silence", { reason: "x", tags: ["a"] }, seen) + expect(invalidParameter(neither)).toBe("duration_minutes") + expect(seen.created).toBeUndefined() + }) + + it("list_alert_silences shows active and scheduled silences by default, not ended ones", async () => { + const seen = fresh() + const result = await ok("list_alert_silences", {}, seen) + const output = Schema.decodeUnknownSync(ListAlertSilencesOutput)(result.structuredContent) + expect(Arr.map(output.silences, (s) => s.id)).toEqual([ACTIVE_ID, SCHEDULED_ID]) + expect(output.status).toBeUndefined() + expect(text(result)).not.toContain("Old outage") + + const ended = await ok("list_alert_silences", { status: "ended" }, fresh()) + expect(text(ended)).toContain("Old outage") + }) + + it("end_alert_silence ends by id and maps an unknown one to silence_id", async () => { + const seen = fresh() + const result = await ok("end_alert_silence", { silence_id: ACTIVE_ID }, seen) + expect(seen.ended).toBe(ACTIVE_ID) + expect(text(result)).toContain("Status: ended") + + const missing = await run("end_alert_silence", { silence_id: INCIDENT_ID }, fresh()) + expect(invalidParameter(missing)).toBe("silence_id") + const malformed = await run("end_alert_silence", { silence_id: "nope" }, fresh()) + expect(invalidParameter(malformed)).toBe("silence_id") + }) + + it("get_incident_timeline names the silence holding back an incident's notifications", async () => { + const result = await ok("get_incident_timeline", {}, fresh()) + const output = Schema.decodeUnknownSync(GetIncidentTimelineOutput)(result.structuredContent) + expect(output.incidents[0]?.silenceReason).toBe("Checkout deploy") + expect(text(result)).toContain(`held back by silence "Checkout deploy" (${ACTIVE_ID})`) + }) +}) diff --git a/apps/ai/src/mcp/tools/create-alert-silence.ts b/apps/ai/src/mcp/tools/create-alert-silence.ts new file mode 100644 index 0000000000..7469f456d2 --- /dev/null +++ b/apps/ai/src/mcp/tools/create-alert-silence.ts @@ -0,0 +1,117 @@ +import { McpInvalidInputError, type McpToolRegistrar } from "./types" +import { Clock, DateTime, Effect, Schema } from "effect" +import { AlertSilenceCreateRequest } from "@maple/domain/http" +import { AlertSilenceMutationOutput } from "@maple/domain/mcp-outputs" +import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" +import { parseWarehouseDateTime } from "@maple/query-engine" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import { silenceFields, createSilenceErrors, toAlertSilenceRow } from "../lib/alert-silences" +import * as P from "../lib/params" +import { doc } from "../lib/tool-doc" + +const decodeCreateRequest = Schema.decodeUnknownEffect(AlertSilenceCreateRequest) +const isoAt = (ms: number) => DateTime.formatIso(DateTime.makeUnsafe(ms)) + +/** Only the matcher lists that hold a value, so an empty list never reaches the service. */ +const nonEmpty = (key: K, values: ReadonlyArray | undefined) => + values === undefined || values.length === 0 ? undefined : { [key]: values } + +export function registerCreateAlertSilenceTool(server: McpToolRegistrar) { + server.define({ + name: "create_alert_silence", + title: "Create Alert Silence", + description: + "Hold back alert notifications for matching rules during a window (maintenance, a known outage). Incidents still open and resolve; only delivery is held. " + + "Matchers AND together, values inside one OR; pass at least one. services/environments must cover a rule's whole scope or the incident's group. " + + "Pass duration_minutes or ends_at (max 30 days).", + parameters: Schema.Struct({ + reason: P.text( + "Why notifications are held back; shown on the silence and on held-back incidents", + ), + rule_ids: P.optionalResourceIdList( + "alertRule", + "Silence these alert rules (ids from list_alert_rules)", + ), + tags: P.optionalList("Silence rules carrying any of these tags"), + services: P.optionalList("Silence rules and incident groups covered by these services"), + environments: P.optionalList("Silence rules and incident groups covered by these environments"), + duration_minutes: P.optionalNumber("How long the silence lasts from its start, in minutes"), + ends_at: P.optionalTimestamp("When the silence ends"), + starts_at: P.optionalTimestamp("When the silence starts (default: now)"), + }), + aliases: { service_names: "services" }, + output: AlertSilenceMutationOutput, + hints: { readOnly: false, destructive: false, idempotent: false }, + phrases: ["Creating an alert silence", "Silencing alerts"], + handler: Effect.fn("McpTool.createAlertSilence")(function* (params) { + if ((params.duration_minutes === undefined) === (params.ends_at === undefined)) { + return yield* new McpInvalidInputError({ + message: + params.ends_at === undefined + ? "Say when the silence ends: pass duration_minutes or ends_at." + : "Pass duration_minutes or ends_at, not both.", + parameter: "duration_minutes", + }) + } + if (params.duration_minutes !== undefined && params.duration_minutes <= 0) { + return yield* new McpInvalidInputError({ + message: "duration_minutes must be positive.", + parameter: "duration_minutes", + }) + } + + const ruleIds = yield* Effect.forEach( + params.rule_ids ?? [], + P.decodeResourceId("alertRule", "rule_ids"), + ) + const matchers = { + ...nonEmpty("ruleIds", ruleIds), + ...nonEmpty("tags", params.tags), + ...nonEmpty("services", params.services), + ...nonEmpty("environments", params.environments), + } + if (Object.keys(matchers).length === 0) { + return yield* new McpInvalidInputError({ + message: + "A silence needs at least one matcher: rule_ids, tags, services or environments. Use list_alert_rules to find them.", + parameter: "rule_ids", + }) + } + + const nowMs = yield* Clock.currentTimeMillis + const startsAtMs = + params.starts_at === undefined ? nowMs : parseWarehouseDateTime(params.starts_at) + const endsAtMs = + params.ends_at === undefined + ? startsAtMs + (params.duration_minutes ?? 0) * 60_000 + : parseWarehouseDateTime(params.ends_at) + const request = yield* decodeCreateRequest({ + reason: params.reason, + matchers, + ...(params.starts_at === undefined ? undefined : { startsAt: isoAt(startsAtMs) }), + endsAt: isoAt(endsAtMs), + }).pipe( + Effect.mapError( + (error) => + new McpInvalidInputError({ message: `Invalid alert silence: ${String(error)}` }), + ), + ) + + const tenant = yield* CurrentMcpTenant + const silences = yield* AlertSilencesService + const silence = yield* silences + .create(tenant.orgId, tenant.userId, tenant.roles, request) + .pipe(Effect.catchTags(createSilenceErrors)) + + return { silence: toAlertSilenceRow(silence) } + }), + render: (output) => ({ + title: "Alert Silence Created", + blocks: [silenceFields(output.silence)], + next: [ + doc.next("list_alert_silences", {}, "every active and scheduled silence"), + doc.next("end_alert_silence", { silence_id: output.silence.id }, "end it early"), + ], + }), + }) +} diff --git a/apps/ai/src/mcp/tools/end-alert-silence.ts b/apps/ai/src/mcp/tools/end-alert-silence.ts new file mode 100644 index 0000000000..f9c2c0fb2a --- /dev/null +++ b/apps/ai/src/mcp/tools/end-alert-silence.ts @@ -0,0 +1,37 @@ +import type { McpToolRegistrar } from "./types" +import { Effect, Schema } from "effect" +import { AlertSilenceMutationOutput } from "@maple/domain/mcp-outputs" +import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import { silenceFields, endSilenceErrors, toAlertSilenceRow } from "../lib/alert-silences" +import * as P from "../lib/params" +import { doc } from "../lib/tool-doc" + +export function registerEndAlertSilenceTool(server: McpToolRegistrar) { + server.define({ + name: "end_alert_silence", + title: "End Alert Silence", + description: + "End an alert silence now so notifications resume; a scheduled one is cancelled. Ids from list_alert_silences.", + parameters: Schema.Struct({ + silence_id: P.resourceId("alertSilence", "Silence to end"), + }), + output: AlertSilenceMutationOutput, + hints: { readOnly: false, destructive: false, idempotent: true }, + phrases: ["Ending an alert silence"], + handler: Effect.fn("McpTool.endAlertSilence")(function* (params) { + const silenceId = yield* P.decodeResourceId("alertSilence", "silence_id")(params.silence_id) + const tenant = yield* CurrentMcpTenant + const silences = yield* AlertSilencesService + const silence = yield* silences + .end(tenant.orgId, tenant.userId, tenant.roles, silenceId) + .pipe(Effect.catchTags(endSilenceErrors)) + return { silence: toAlertSilenceRow(silence) } + }), + render: (output) => ({ + title: "Alert Silence Ended", + blocks: [silenceFields(output.silence)], + next: [doc.next("list_alert_silences", {}, "the silences still active or scheduled")], + }), + }) +} diff --git a/apps/ai/src/mcp/tools/get-incident-timeline.ts b/apps/ai/src/mcp/tools/get-incident-timeline.ts index 3b89f78735..7714f5cf78 100644 --- a/apps/ai/src/mcp/tools/get-incident-timeline.ts +++ b/apps/ai/src/mcp/tools/get-incident-timeline.ts @@ -1,8 +1,10 @@ import { McpQueryError, type McpToolRegistrar } from "./types" -import { Effect, Schema, Array as Arr, String as Str } from "effect" +import { Effect, Option, Schema, Array as Arr, String as Str } from "effect" import { GetIncidentTimelineOutput } from "@maple/domain/mcp-outputs" import { CurrentMcpTenant } from "../lib/query-warehouse" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" +import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" +import type { AlertSilenceId, OrgId } from "@maple/domain/primitives" import { ALERT_INCIDENT_STATUSES, ALERT_SEVERITIES, formatCondition, parseRuleId } from "../lib/alert-rules" import * as P from "../lib/params" import { doc, type DocBlock } from "../lib/tool-doc" @@ -16,6 +18,39 @@ function formatTimestamp(iso: string | null): string | undefined { return Str.replace("T", " ")(Str.slice(0, 19)(iso)) } +/** Reasons of the silences these incidents name; one that cannot be read is left out. */ +const silenceReasons = Effect.fn("McpTool.getIncidentTimeline.silenceReasons")(function* ( + orgId: OrgId, + ids: ReadonlyArray, +) { + if (ids.length === 0) return new Map() + const silences = yield* AlertSilencesService + const found = yield* Effect.forEach( + ids, + (id) => + silences.get(orgId, id).pipe( + Effect.map((silence) => [[silence.id, silence.reason] as const]), + Effect.orElseSucceed(() => []), + ), + { concurrency: 4 }, + ) + return new Map(Arr.flatten(found)) +}) + +/** How a held-back notification reads: still owed while `silencedAt` is set. */ +function silenceLine(inc: { + readonly silenceId?: string | null + readonly silencedAt?: string | null + readonly silenceReason?: string +}): string | undefined { + if (inc.silenceId === undefined || inc.silenceId === null) return undefined + const named = + inc.silenceReason === undefined ? inc.silenceId : `"${inc.silenceReason}" (${inc.silenceId})` + return inc.silencedAt === undefined || inc.silencedAt === null + ? `last notification was held back by silence ${named}` + : `held back by silence ${named} since ${formatTimestamp(inc.silencedAt)}` +} + export function registerGetIncidentTimelineTool(server: McpToolRegistrar) { server.define({ name: "get_incident_timeline", @@ -65,6 +100,10 @@ export function registerGetIncidentTimelineTool(server: McpToolRegistrar) { (params.group_key === undefined || i.groupKey === params.group_key), ) const incidents = matching.slice(0, params.limit) + const reasons = yield* silenceReasons( + tenant.orgId, + Arr.dedupe(Arr.flatMap(incidents, (i) => (i.silenceId === null ? [] : [i.silenceId]))), + ) return { incidents: Arr.map(incidents, (i) => ({ @@ -82,6 +121,12 @@ export function registerGetIncidentTimelineTool(server: McpToolRegistrar) { lastTriggeredAt: i.lastTriggeredAt, resolvedAt: i.resolvedAt, lastNotifiedAt: i.lastNotifiedAt, + silenceId: i.silenceId, + silencedAt: i.silencedAt, + ...Option.match(Option.fromUndefinedOr(reasons.get(i.silenceId ?? "")), { + onNone: () => undefined, + onSome: (silenceReason) => ({ silenceReason }), + }), })), total: incidents.length, openCount: Arr.filter(incidents, (i) => i.status === "open").length, @@ -120,6 +165,7 @@ export function registerGetIncidentTimelineTool(server: McpToolRegistrar) { ["Last triggered", formatTimestamp(inc.lastTriggeredAt)], ["Resolved at", formatTimestamp(inc.resolvedAt)], ["Last notified", formatTimestamp(inc.lastNotifiedAt)], + ["Notifications", silenceLine(inc)], ["Incident ID", inc.id], ["Rule ID", inc.ruleId], ]), diff --git a/apps/ai/src/mcp/tools/list-alert-silences.ts b/apps/ai/src/mcp/tools/list-alert-silences.ts new file mode 100644 index 0000000000..42cd23fa38 --- /dev/null +++ b/apps/ai/src/mcp/tools/list-alert-silences.ts @@ -0,0 +1,114 @@ +import type { McpToolRegistrar } from "./types" +import { Array as Arr, Effect, Schema } from "effect" +import { ListAlertSilencesOutput } from "@maple/domain/mcp-outputs" +import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import { toMcpHttpError } from "../lib/map-http-error" +import { ALERT_SILENCE_STATUSES, formatSilenceMatchers, toAlertSilenceRow } from "../lib/alert-silences" +import * as P from "../lib/params" +import { doc } from "../lib/tool-doc" + +const MAX_LIMIT = 200 + +export function registerListAlertSilencesTool(server: McpToolRegistrar) { + server.define({ + name: "list_alert_silences", + title: "List Alert Silences", + description: + "List alert silences: windows that hold back alert notifications for matching rules (incidents still open and resolve). " + + "Defaults to active and scheduled silences; pass status=ended for past ones.", + parameters: Schema.Struct({ + status: P.optionalOneOf( + ALERT_SILENCE_STATUSES, + "Only silences in this status (default: active and scheduled)", + ), + limit: P.limit({ default: 50, max: MAX_LIMIT, noun: "silences" }), + }), + output: ListAlertSilencesOutput, + hints: { readOnly: true }, + phrases: ["Listing alert silences", "Checking alert silences"], + handler: Effect.fn("McpTool.listAlertSilences")(function* (params) { + const tenant = yield* CurrentMcpTenant + const silences = yield* AlertSilencesService + const statuses = + params.status === undefined ? (["active", "scheduled"] as const) : [params.status] + + const pages = yield* Effect.forEach(statuses, (status) => + silences + .list(tenant.orgId, { status, limit: params.limit + 1 }) + .pipe(Effect.mapError(toMcpHttpError("list_alert_silences"))), + ) + const all = Arr.flatMap(pages, (page) => page.silences) + const shown = all.slice(0, params.limit) + + yield* Effect.annotateCurrentSpan({ + orgId: tenant.orgId, + "maple.ai.silence_status": params.status ?? "open", + "result.rowCount": shown.length, + }) + + return { + silences: Arr.map(shown, toAlertSilenceRow), + total: shown.length, + ...(params.status === undefined ? undefined : { status: params.status }), + limit: params.limit, + hasMore: all.length > params.limit, + } + }), + render: (output) => ({ + title: "Alert Silences", + scope: [["Status", output.status ?? "active, scheduled"]], + ...(output.silences.length === 0 + ? { + empty: { + message: + output.status === undefined + ? "No active or scheduled alert silences." + : `No ${output.status} alert silences.`, + hints: ["Create one with create_alert_silence to hold back notifications."], + }, + } + : undefined), + blocks: + output.silences.length === 0 + ? [] + : [ + doc.table( + ["ID", "Status", "Reason", "Matches", "Starts", "Ends", "Created by"], + Arr.map(output.silences, (s) => [ + s.id, + s.status, + s.reason, + formatSilenceMatchers(s.matchers), + s.startsAt, + s.endsAt, + s.createdBy, + ]), + ), + ], + ...(output.hasMore + ? { + truncation: { + shown: output.silences.length, + noun: "silences", + ...(output.limit < MAX_LIMIT + ? { + next: doc.next( + "list_alert_silences", + { status: output.status, limit: MAX_LIMIT }, + "more silences", + ), + } + : undefined), + }, + } + : undefined), + next: [ + ...Arr.map(Arr.filter(output.silences, (s) => s.status !== "ended").slice(0, 1), (s) => + doc.next("end_alert_silence", { silence_id: s.id }, `end "${s.reason}" now`), + ), + doc.next("list_alert_rules", {}, "find rule ids and tags to silence"), + ], + }), + }) +} diff --git a/apps/ai/src/mcp/tools/mutating.ts b/apps/ai/src/mcp/tools/mutating.ts index 8f50153e5d..c012920fe2 100644 --- a/apps/ai/src/mcp/tools/mutating.ts +++ b/apps/ai/src/mcp/tools/mutating.ts @@ -18,6 +18,8 @@ export const MUTATING_TOOL_NAMES: ReadonlySet = new Set([ "create_alert_rule", "update_alert_rule", "delete_alert_rule", + "create_alert_silence", + "end_alert_silence", // error issues "claim_error_issue", "release_error_issue", diff --git a/apps/ai/src/mcp/tools/registry.ts b/apps/ai/src/mcp/tools/registry.ts index 9cba7fac8c..d7f526f004 100644 --- a/apps/ai/src/mcp/tools/registry.ts +++ b/apps/ai/src/mcp/tools/registry.ts @@ -10,6 +10,9 @@ import { registerCreateAlertRuleTool } from "./create-alert-rule" import { registerPreviewAlertRuleTool } from "./preview-alert-rule" import { registerUpdateAlertRuleTool } from "./update-alert-rule" import { registerDeleteAlertRuleTool } from "./delete-alert-rule" +import { registerListAlertSilencesTool } from "./list-alert-silences" +import { registerCreateAlertSilenceTool } from "./create-alert-silence" +import { registerEndAlertSilenceTool } from "./end-alert-silence" import { registerCreateDashboardTool } from "./create-dashboard" import { registerDescribeDashboardSchemaTool } from "./describe-dashboard-schema" import { registerDiagnoseServiceTool } from "./diagnose-service" @@ -429,6 +432,9 @@ const collectMapleToolDefinitions = (): ReadonlyArray => { registerCreateAlertRuleTool(registrar) registerUpdateAlertRuleTool(registrar) registerDeleteAlertRuleTool(registrar) + registerListAlertSilencesTool(registrar) + registerCreateAlertSilenceTool(registrar) + registerEndAlertSilenceTool(registrar) registerDescribeDashboardSchemaTool(registrar) registerListDashboardsTool(registrar) registerGetDashboardTool(registrar) diff --git a/apps/ai/src/mcp/tools/runtime-requirements.ts b/apps/ai/src/mcp/tools/runtime-requirements.ts index 9cacd010ac..002cd1b061 100644 --- a/apps/ai/src/mcp/tools/runtime-requirements.ts +++ b/apps/ai/src/mcp/tools/runtime-requirements.ts @@ -3,6 +3,7 @@ import type { AgentFeedbackService } from "@maple/backend/services/feedback/Agen import type { AlertsService } from "@maple/backend/services/alerts/AlertsService" import type { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" import type { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" +import type { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" import type { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService" import type { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService" import type { ErrorIssueReadModelsService } from "@maple/backend/services/errors/ErrorIssueReadModelsService" @@ -46,6 +47,7 @@ export type McpToolRuntimeRequirements = | DependencyScanReader | AlertReadModelsService | AlertRulesService + | AlertSilencesService | DashboardPersistenceService | ErrorActorsService | ErrorIssueReadModelsService diff --git a/apps/ai/test/mcp/unimplemented-tool-services.ts b/apps/ai/test/mcp/unimplemented-tool-services.ts index 8e2574fd24..b6a9a9b832 100644 --- a/apps/ai/test/mcp/unimplemented-tool-services.ts +++ b/apps/ai/test/mcp/unimplemented-tool-services.ts @@ -6,6 +6,7 @@ import { AgentFeedbackService } from "@maple/backend/services/feedback/AgentFeed import { AlertsService } from "@maple/backend/services/alerts/AlertsService" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" 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" import { ErrorIssueReadModelsService } from "@maple/backend/services/errors/ErrorIssueReadModelsService" @@ -42,6 +43,7 @@ export const unimplementedMcpToolServices = Layer.mergeAll( Layer.mock(AlertsService)({}), Layer.mock(AlertReadModelsService)({}), Layer.mock(AlertRulesService)({}), + Layer.mock(AlertSilencesService)({}), Layer.mock(DashboardPersistenceService)({}), Layer.mock(ErrorActorsService)({}), Layer.mock(ErrorIssueReadModelsService)({}), diff --git a/apps/landing/src/content/docs/reference/mcp.md b/apps/landing/src/content/docs/reference/mcp.md index 82a9aba088..77e2f3d0f7 100644 --- a/apps/landing/src/content/docs/reference/mcp.md +++ b/apps/landing/src/content/docs/reference/mcp.md @@ -114,6 +114,9 @@ Tools that take an alert rule, alert destination, dashboard or error issue ID ac | `create_alert_rule` | write | Create an alert rule from a template or from signal type, comparator and threshold | | `update_alert_rule` | write | Change the fields you pass on an alert rule | | `delete_alert_rule` | write | Delete an alert rule and its incident history. Requires `confirm=true` | +| `list_alert_silences` | read | Active and scheduled alert silences, or ended ones, with their matchers and window | +| `create_alert_silence` | write | Hold back notifications for matching rules for a duration or until a time | +| `end_alert_silence` | write | End a silence now so notifications resume; cancels a scheduled one | ### Dashboards diff --git a/apps/web/src/page/investigations/conversation/kit/tool-metadata.ts b/apps/web/src/page/investigations/conversation/kit/tool-metadata.ts index a9f025bd53..f89d163d54 100644 --- a/apps/web/src/page/investigations/conversation/kit/tool-metadata.ts +++ b/apps/web/src/page/investigations/conversation/kit/tool-metadata.ts @@ -60,6 +60,9 @@ const TOOL_LABELS = new Map([ ["create_alert_rule", "Create Alert Rule"], ["update_alert_rule", "Update Alert Rule"], ["delete_alert_rule", "Delete Alert Rule"], + ["list_alert_silences", "List Alert Silences"], + ["create_alert_silence", "Create Alert Silence"], + ["end_alert_silence", "End Alert Silence"], ["list_alert_incidents", "List Alert Incidents"], ["list_alert_checks", "List Alert Checks"], ["preview_alert_rule", "Preview Alert Rule"], @@ -129,6 +132,9 @@ const TOOL_ICONS = new Map([ ["create_alert_rule", "BellIcon"], ["update_alert_rule", "BellIcon"], ["delete_alert_rule", "TrashIcon"], + ["list_alert_silences", "BellIcon"], + ["create_alert_silence", "BellIcon"], + ["end_alert_silence", "BellIcon"], ["list_alert_incidents", "AlertWarningIcon"], ["list_alert_checks", "CircleCheckIcon"], ["preview_alert_rule", "HistoryIcon"], diff --git a/packages/domain/src/mcp-outputs/alert-silences.ts b/packages/domain/src/mcp-outputs/alert-silences.ts new file mode 100644 index 0000000000..0061f3db84 --- /dev/null +++ b/packages/domain/src/mcp-outputs/alert-silences.ts @@ -0,0 +1,33 @@ +/** Output schemas for the alert silence MCP tools. */ +import { Schema } from "effect" + +const MatcherValues = Schema.optionalKey(Schema.Array(Schema.String)) + +export const AlertSilenceMatchersRow = Schema.Struct({ + ruleIds: MatcherValues, + tags: MatcherValues, + services: MatcherValues, + environments: MatcherValues, +}) + +export const AlertSilenceRow = Schema.Struct({ + id: Schema.String, + reason: Schema.String, + matchers: AlertSilenceMatchersRow, + /** scheduled, active or ended. */ + status: Schema.String, + startsAt: Schema.String, + endsAt: Schema.String, + createdBy: Schema.String, +}) + +export const ListAlertSilencesOutput = Schema.Struct({ + silences: Schema.Array(AlertSilenceRow), + total: Schema.Number, + /** The status filter; absent means active and scheduled. */ + status: Schema.optionalKey(Schema.String), + limit: Schema.Number, + hasMore: Schema.Boolean, +}) + +export const AlertSilenceMutationOutput = Schema.Struct({ silence: AlertSilenceRow }) diff --git a/packages/domain/src/mcp-outputs/alerts.ts b/packages/domain/src/mcp-outputs/alerts.ts index 438bc6ebe6..06ad41a3f1 100644 --- a/packages/domain/src/mcp-outputs/alerts.ts +++ b/packages/domain/src/mcp-outputs/alerts.ts @@ -298,6 +298,12 @@ export const IncidentTimelineRow = Schema.Struct({ lastTriggeredAt: Schema.String, resolvedAt: NullableString, lastNotifiedAt: NullableString, + /** The silence that last held back a notification for this incident. */ + silenceId: Schema.optionalKey(NullableString), + /** Set while a silenced notification is still owed. */ + silencedAt: Schema.optionalKey(NullableString), + /** The silence's reason, when it could be read. */ + silenceReason: Schema.optionalKey(Schema.String), }) export const GetIncidentTimelineOutput = Schema.Struct({ diff --git a/packages/domain/src/mcp-outputs/catalog.ts b/packages/domain/src/mcp-outputs/catalog.ts index 0d9d871079..101de42f34 100644 --- a/packages/domain/src/mcp-outputs/catalog.ts +++ b/packages/domain/src/mcp-outputs/catalog.ts @@ -33,6 +33,7 @@ import { SyncRailwayOutput, } from "./integrations" import { CiRunsOutput, CompareCommitsOutput } from "./ci" +import { AlertSilenceMutationOutput, ListAlertSilencesOutput } from "./alert-silences" import { CreateAlertRuleOutput, DeleteAlertRuleOutput, @@ -139,6 +140,7 @@ export const McpToolOutputs = { compare_commits: CompareCommitsOutput, compare_periods: ComparePeriodsOutput, create_alert_rule: CreateAlertRuleOutput, + create_alert_silence: AlertSilenceMutationOutput, create_ingest_rule: IngestRuleMutationOutput, create_scrape_target: ScrapeTargetMutationOutput, create_dashboard: CreateDashboardOutput, @@ -152,6 +154,7 @@ export const McpToolOutputs = { describe_dashboard_schema: DescribeDashboardSchemaOutput, describe_warehouse_tables: DescribeWarehouseTablesOutput, diagnose_service: DiagnoseServiceOutput, + end_alert_silence: AlertSilenceMutationOutput, error_detail: ErrorDetailOutput, explore_attributes: ExploreAttributesOutput, find_errors: FindErrorsOutput, @@ -184,6 +187,7 @@ export const McpToolOutputs = { list_alert_destinations: ListAlertDestinationsOutput, list_alert_incidents: ListAlertIncidentsOutput, list_alert_rules: ListAlertRulesOutput, + list_alert_silences: ListAlertSilencesOutput, list_dashboards: ListDashboardsOutput, list_error_incidents: ListErrorIncidentsOutput, list_error_issue_events: ListErrorIssueEventsOutput, diff --git a/packages/domain/src/mcp-outputs/index.ts b/packages/domain/src/mcp-outputs/index.ts index 121daed48f..6494cb4af5 100644 --- a/packages/domain/src/mcp-outputs/index.ts +++ b/packages/domain/src/mcp-outputs/index.ts @@ -4,6 +4,7 @@ export * from "./traces" export * from "./services" export * from "./sessions" export * from "./alerts" +export * from "./alert-silences" export * from "./ingest-rules" export * from "./dashboards" export * from "./issues" From ea07154110d38447df27bab6ec84d6c7c78f1451 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 01:44:12 +0100 Subject: [PATCH 03/28] feat(web): silence alerts from the hub, a rule or an incident A Silences tab in the alerts hub lists active, scheduled and recently ended silences, with End now / Cancel and a New silence dialog scoped by service, environment and tag. Rule and incident pages get a Silence action (1 hour, 4 hours, until 09:00 tomorrow, or a custom end in the viewer's zone) and a "Silenced until" badge; an incident on a rule grouped by service silences just its group. The incident timeline names pages a silence held back. --- apps/web/src/page/alerts/hub/command.ts | 23 +- apps/web/src/page/alerts/hub/derive.ts | 4 +- .../alerts/hub/hub-silences.scene.test.ts | 148 ++++++++++ apps/web/src/page/alerts/hub/message.ts | 16 +- apps/web/src/page/alerts/hub/model.ts | 14 +- apps/web/src/page/alerts/hub/update.ts | 97 ++++++- apps/web/src/page/alerts/hub/view-silences.ts | 175 ++++++++++++ apps/web/src/page/alerts/hub/view.ts | 53 +++- apps/web/src/page/alerts/incident/command.ts | 11 + apps/web/src/page/alerts/incident/message.ts | 9 +- apps/web/src/page/alerts/incident/model.ts | 6 +- apps/web/src/page/alerts/incident/update.ts | 131 +++++++-- apps/web/src/page/alerts/incident/view.ts | 49 +++- apps/web/src/page/alerts/rule/command.ts | 11 + apps/web/src/page/alerts/rule/message.ts | 25 +- apps/web/src/page/alerts/rule/model.ts | 5 + apps/web/src/page/alerts/rule/update.ts | 74 ++++- apps/web/src/page/alerts/rule/view.ts | 49 +++- .../src/page/alerts/shared/silence-view.ts | 25 ++ .../src/page/alerts/shared/silences.test.ts | 63 +++++ apps/web/src/page/alerts/shared/silences.ts | 111 ++++++++ .../page/alerts/silence-dialog/dialog.test.ts | 61 ++++ .../src/page/alerts/silence-dialog/dialog.ts | 261 ++++++++++++++++++ .../src/page/alerts/silence-dialog/view.ts | 170 ++++++++++++ packages/views/src/ui/icons.generated.ts | 224 +++++++++++++++ .../src/alerts/incident-timeline.test.ts | 7 + .../web-data/src/alerts/incident-timeline.ts | 37 ++- 27 files changed, 1788 insertions(+), 71 deletions(-) create mode 100644 apps/web/src/page/alerts/hub/hub-silences.scene.test.ts create mode 100644 apps/web/src/page/alerts/hub/view-silences.ts create mode 100644 apps/web/src/page/alerts/shared/silence-view.ts create mode 100644 apps/web/src/page/alerts/shared/silences.test.ts create mode 100644 apps/web/src/page/alerts/shared/silences.ts create mode 100644 apps/web/src/page/alerts/silence-dialog/dialog.test.ts create mode 100644 apps/web/src/page/alerts/silence-dialog/dialog.ts create mode 100644 apps/web/src/page/alerts/silence-dialog/view.ts diff --git a/apps/web/src/page/alerts/hub/command.ts b/apps/web/src/page/alerts/hub/command.ts index c60a1f4b36..908020f366 100644 --- a/apps/web/src/page/alerts/hub/command.ts +++ b/apps/web/src/page/alerts/hub/command.ts @@ -1,5 +1,5 @@ import { UptimeMonitorId } from "@maple/domain" -import { AlertDestinationId, AlertRuleId } from "@maple/domain/http" +import { AlertDestinationId, AlertRuleId, AlertSilenceId } from "@maple/domain/http" import { V2AlertDestinationUpdateParams } from "@maple/domain/http/v2" import { formatWarehouseDateTime, snapRangeForCache } from "@maple/query-engine" import { getServiceOverview } from "@maple/web-data/warehouse/services" @@ -18,6 +18,7 @@ import { updateDestination, } from "../shared/commands" import { deleteMonitor, listMonitors, setMonitorEnabled } from "../shared/uptime-commands" +import { endSilence, listSilences } from "../shared/silences" import { Message } from "./message" import { ServiceNamespaces, serviceNamespaces } from "./uptime-groups" @@ -152,3 +153,23 @@ export const DeleteMonitor = Command.define("DeleteUptimeMonitor", { Effect.map((result) => Message.CompletedDeleteMonitor({ monitorId, result })), ), }) + +export const FetchHubSilences = Command.define("FetchAlertsHubSilences", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchSilences], + execute: ({ key }) => + listSilences.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchSilences({ key, result })), + ), +}) + +export const EndHubSilence = Command.define("EndAlertSilence", { + args: { silenceId: AlertSilenceId }, + messages: [Message.CompletedEndSilence], + execute: ({ silenceId }) => + endSilence(silenceId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedEndSilence({ silenceId, result })), + ), +}) diff --git a/apps/web/src/page/alerts/hub/derive.ts b/apps/web/src/page/alerts/hub/derive.ts index 16ade20842..7ee60b28a3 100644 --- a/apps/web/src/page/alerts/hub/derive.ts +++ b/apps/web/src/page/alerts/hub/derive.ts @@ -53,7 +53,7 @@ const destinationsFor = lastBy( export const destinationsOf = (model: Model): ReadonlyArray => destinationsFor([model.destinations]) -export type AlertsTab = "inbox" | "rules" | "uptime" | "routing" +export type AlertsTab = "inbox" | "rules" | "uptime" | "silences" | "routing" /** Legacy `tab` values map onto the current tabs so old links keep resolving. */ export const resolveTab = (tab: string | undefined): AlertsTab => { @@ -62,6 +62,8 @@ export const resolveTab = (tab: string | undefined): AlertsTab => { return "rules" case "uptime": return "uptime" + case "silences": + return "silences" case "routing": case "settings": return "routing" diff --git a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts new file mode 100644 index 0000000000..074e18639e --- /dev/null +++ b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts @@ -0,0 +1,148 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { AlertSilenceId } from "@maple/domain/http" +import type { V2AlertSilence } from "@maple/domain/http/v2" +import { Result, Schema, Array as Arr } from "effect" +import * as Animation from "@foldkit/ui/animation" +import * as Dialog from "@maple/views/ui/dialog" +import { MountDialog } from "@maple/views/ui/dialog" +import { + click, + Command, + expect as expectView, + Mount, + given, + role, + scene, + text, + withViewInputs, +} from "foldkit/scene" +import { describe, expect, test } from "vitest" +import type { PageContext } from "../../contract" +import { NOW, ORG } from "../shared/test-rows" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const asSilenceId = Schema.decodeSync(AlertSilenceId) +const iso = (ms: number) => new Date(ms).toISOString() + +const silence = ( + overrides: Partial & { readonly startMin: number; readonly endMin: number }, +): V2AlertSilence => { + const { startMin, endMin, ...rest } = overrides + return { + id: asSilenceId("22222222-2222-4222-8222-222222222222"), + object: "alert_silence", + reason: "Deploying checkout v2.3", + matchers: { services: ["checkout"] }, + status: "active", + starts_at: iso(NOW + startMin * 60_000), + ends_at: iso(NOW + endMin * 60_000), + created_by: "user_1", + created_at: iso(NOW), + updated_at: iso(NOW), + ...rest, + } +} + +const onSilences = (grants: ReturnType, silences: ReadonlyArray) => { + const entered = init({ _tag: "Alerts", search: { tab: "silences" } }, context) + expect(Arr.map(entered.commands ?? [], ({ name }) => name)).toContain("FetchAlertsHubSilences") + return Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.CompletedFetchSession({ orgId: ORG, grants }), + Message.CompletedFetchSilences({ + key: entered.model.silences.key ?? "", + result: Result.succeed(silences), + }), + ], + entered.model, + (model, message) => pageUpdate(model, message).model, + ) +} + +const ENGINEER = Policy.grantsOf(["org:engineer"]) +const MEMBER = Policy.grantsOf(["org:member"]) + +describe("alerts hub, Silences tab", () => { + test("an org without silences explains them and offers the first one", () => { + scene( + { update: pageUpdate, view }, + given(onSilences(ENGINEER, [])), + expectView(text("No silences")).toExist(), + expectView(role("button", { name: "New silence" })).toExist(), + ) + }) + + test("groups silences by where they stand, with their window and scope", () => { + scene( + { update: pageUpdate, view }, + given( + onSilences(ENGINEER, [ + silence({ startMin: -10, endMin: 50 }), + silence({ + id: asSilenceId("33333333-3333-4333-8333-333333333333"), + reason: "Database migration", + matchers: { tags: ["db"] }, + startMin: 120, + endMin: 180, + }), + ]), + ), + expectView(text("Deploying checkout v2.3")).toExist(), + expectView(text(/Until .+ · service checkout/u)).toExist(), + expectView(text(/tag db/u)).toExist(), + expectView(role("button", { name: "End now" })).toExist(), + expectView(role("button", { name: "Cancel" })).toExist(), + ) + }) + + test("ending a silence sends it once and a member cannot end one", () => { + const model = onSilences(ENGINEER, [silence({ startMin: -10, endMin: 50 })]) + const step = pageUpdate( + model, + Message.ClickedEndSilence({ silenceId: asSilenceId("22222222-2222-4222-8222-222222222222") }), + ) + expect(Arr.map(step.commands ?? [], ({ name }) => name)).toEqual(["EndAlertSilence"]) + expect( + pageUpdate( + step.model, + Message.ClickedEndSilence({ silenceId: asSilenceId("22222222-2222-4222-8222-222222222222") }), + ).commands ?? [], + ).toEqual([]) + + scene( + { update: pageUpdate, view }, + given(onSilences(MEMBER, [silence({ startMin: -10, endMin: 50 })])), + expectView(role("button", { name: "End now" })).not.toExist(), + expectView(role("button", { name: "New silence" })).not.toExist(), + ) + }) + + test("New silence opens the dialog with typed scope fields", () => { + scene( + { update: pageUpdate, view }, + given(onSilences(ENGINEER, [])), + click(role("button", { name: "New silence" })), + Command.resolve( + Animation.WaitForPaint, + Animation.Message.CompletedWaitForPaint({ generation: 1 }), + ), + Command.resolve( + Animation.WaitForAnimationSettled, + Animation.Message.EndedAnimation({ generation: 1 }), + ), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(role("dialog", { name: "Silence notifications" })).toExist(), + expectView(role("textbox", { name: "Services" })).toExist(), + expectView(role("button", { name: "Silence for 1 hour" })).toBeDisabled(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/hub/message.ts b/apps/web/src/page/alerts/hub/message.ts index 8423043357..4f3dd197ac 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -1,7 +1,12 @@ import * as Policy from "@maple/domain/policy" import { UptimeMonitorId } from "@maple/domain" -import { AlertDeliveryEventDocument, AlertDestinationId, AlertRuleId } from "@maple/domain/http" -import { V2UptimeMonitor } from "@maple/domain/http/v2" +import { + AlertDeliveryEventDocument, + AlertDestinationId, + AlertRuleId, + AlertSilenceId, +} from "@maple/domain/http" +import { V2AlertSilence, V2UptimeMonitor } from "@maple/domain/http/v2" import { AlertDestinationRowSchema, AlertIncidentRowSchema, @@ -14,6 +19,7 @@ import { LoadFailure } from "@maple/views/data/load-failure" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" +import * as SilenceDialog from "../silence-dialog/dialog" import { TestOutcome, UptimeKindFilter, UptimeStatusFilter } from "./model" import { ServiceNamespaces } from "./uptime-groups" @@ -69,5 +75,11 @@ export const Message = defineMessageUnion({ ClickedConfirmDeleteMonitor: {}, CompletedDeleteMonitor: { monitorId: UptimeMonitorId, result: outcome(Schema.Void) }, GotDeleteMonitorDialogMessage: { message: Dialog.Message }, + CompletedFetchSilences: { key: Schema.String, result: outcome(Schema.Array(V2AlertSilence)) }, + ClickedRetrySilences: {}, + ClickedNewSilence: {}, + GotSilenceDialogMessage: { message: SilenceDialog.Message }, + ClickedEndSilence: { silenceId: AlertSilenceId }, + CompletedEndSilence: { silenceId: AlertSilenceId, result: outcome(V2AlertSilence) }, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index 6b460b6489..e9cbd4c7a7 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -1,7 +1,12 @@ import * as Policy from "@maple/domain/policy" import { UptimeMonitorId } from "@maple/domain" -import { AlertDeliveryEventDocument, AlertDestinationId, AlertRuleId } from "@maple/domain/http" -import { V2UptimeMonitor } from "@maple/domain/http/v2" +import { + AlertDeliveryEventDocument, + AlertDestinationId, + AlertRuleId, + AlertSilenceId, +} from "@maple/domain/http" +import { V2AlertSilence, V2UptimeMonitor } from "@maple/domain/http/v2" import { AlertDestinationRowSchema, AlertIncidentRowSchema, @@ -15,6 +20,7 @@ import { AlertsSearch } from "../../../route/search" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" +import * as SilenceDialog from "../silence-dialog/dialog" import { ServiceNamespaces } from "./uptime-groups" // The `/alerts` hub. @@ -74,6 +80,10 @@ export const Model = Schema.Struct({ deletingMonitorId: Schema.NullOr(UptimeMonitorId), confirmDeleteMonitorId: Schema.NullOr(UptimeMonitorId), deleteMonitorDialog: Dialog.Model, + /** Silences tab: `alertSilences.list`, read while the tab is open. */ + silences: KeyedSchema(Schema.Array(V2AlertSilence), LoadFailure), + endingSilenceIds: Schema.Array(AlertSilenceId), + silenceDialog: SilenceDialog.Model, }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 7319acbf6a..cee1586749 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -1,5 +1,5 @@ import type { UptimeMonitorId } from "@maple/domain" -import type { AlertDestinationId, AlertRuleId } from "@maple/domain/http" +import type { AlertDestinationId, AlertRuleId, AlertSilenceId } from "@maple/domain/http" import { buildDestinationUpdateParamsV2, destinationToFormState } from "@maple/web-data/alerts/form-utils" import { rowToAlertDestinationDocument, rowToAlertRuleDocument } from "@maple/web-data/collections/alerts" import { Array, Option, Result, String as Str } from "effect" @@ -14,6 +14,7 @@ import type { AppRoute } from "../../../route" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" +import * as SilenceDialog from "../silence-dialog/dialog" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" import { alertsHref, createHref, ruleHref } from "../shared/links" import { editMonitorHref, newMonitorHref, UPTIME_LIST_LIMIT } from "../shared/uptime-commands" @@ -30,6 +31,8 @@ import { TestDestination, ToggleRule, UpdateDestination, + EndHubSilence, + FetchHubSilences, } from "./command" import { resolveStatus, resolveTab } from "./derive" import { Message, type RuleBucket } from "./message" @@ -85,6 +88,26 @@ const requestRoutingDeliveries = (model: Model, context: PageContext, mode: Keye }) } +const silencesKey = (context: PageContext) => + requestKey(context.orgId, "alertSilences.list", { query: { limit: 100 } }) + +/** The Silences tab reads the org's silences only while it is open. */ +const requestSilences = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => { + if (resolveTab(model.search.tab) !== "silences") return { model, commands: [] } + return Option.match(Keyed.requestFor(model.silences, silencesKey(context), mode), { + onNone: () => ({ model, commands: [] }), + onSome: (silences) => ({ + model: { ...model, silences }, + commands: [FetchHubSilences({ key: silences.key })], + }), + }) +} + +const refreshSilences = (model: Model, context: PageContext): Return => { + const step = requestSilences(model, context, "Supersede") + return { model: step.model, commands: step.commands } +} + const monitorsKey = (context: PageContext) => requestKey(context.orgId, "uptimeMonitors.list", { query: { limit: UPTIME_LIST_LIMIT } }) @@ -154,18 +177,23 @@ export const init = (route: HubRoute, context: PageContext): Return => { deletingMonitorId: null, confirmDeleteMonitorId: null, deleteMonitorDialog: Dialog.init(DELETE_MONITOR_DIALOG_ID, "alert-dialog"), + silences: Keyed.idle(), + endingSilenceIds: [], + silenceDialog: SilenceDialog.init("alerts-silence"), } const deliveries = requestDeliveries(model, context, "Load") const routing = requestRoutingDeliveries(deliveries.model, context, "Load") const monitors = requestMonitors(routing.model, context, "Load") + const silences = requestSilences(monitors.model, context, "Load") return { - model: monitors.model, + model: silences.model, commands: [ ReadHubClock(), FetchHubSession({ orgId: context.orgId }), ...deliveries.commands, ...routing.commands, ...monitors.commands, + ...silences.commands, ], } } @@ -173,7 +201,11 @@ export const init = (route: HubRoute, context: PageContext): Return => { export const routeChanged = (model: Model, route: HubRoute, context: PageContext): Return => { const routing = requestRoutingDeliveries({ ...model, search: route.search }, context, "Load") const monitors = requestMonitors(routing.model, context, "Load") - return { model: monitors.model, commands: [...routing.commands, ...monitors.commands] } + const silences = requestSilences(monitors.model, context, "Load") + return { + model: silences.model, + commands: [...routing.commands, ...monitors.commands, ...silences.commands, ReadHubClock()], + } } const navigate = (search: Model["search"]): PageOutMessage => @@ -252,6 +284,43 @@ const liftDestinationDialog = (model: Model, step: ReturnType, +): Return => { + const next = { ...model, silenceDialog: step.model } + const commands = FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotSilenceDialogMessage({ message }), + ) + if (step.outMessage === undefined) return { model: next, commands } + return SilenceDialog.OutMessage.match(step.outMessage, { + RequestedToast: ({ type, title, description }) => ({ + model: next, + commands, + outMessage: toast(type, title, description), + }), + CreatedSilence: () => { + const refreshed = refreshSilences(next, context) + return { + model: refreshed.model, + commands: [...commands, ...(refreshed.commands ?? [])], + outMessage: toast("success", "Alerts silenced"), + } + }, + }) +} + +const endSilence = (model: Model, silenceId: AlertSilenceId): Return => + model.endingSilenceIds.includes(silenceId) + ? { model } + : { + model: { ...model, endingSilenceIds: [...model.endingSilenceIds, silenceId] }, + commands: [EndHubSilence({ silenceId })], + } + // ROW MENUS /** @@ -536,6 +605,28 @@ export const update = (model: Model, message: Message, context: PageContext): Re }), }) }, + CompletedFetchSilences: ({ key, result }) => ({ + model: { ...model, silences: Keyed.accept(model.silences, key, result) }, + }), + ClickedRetrySilences: () => refreshSilences(model, context), + ClickedNewSilence: () => + liftSilenceDialog(model, context, SilenceDialog.openNew(model.silenceDialog, context.timeZone)), + GotSilenceDialogMessage: ({ message: child }) => + liftSilenceDialog(model, context, SilenceDialog.update(model.silenceDialog, child)), + ClickedEndSilence: ({ silenceId }) => endSilence(model, silenceId), + CompletedEndSilence: ({ silenceId, result }) => { + const settled = { ...model, endingSilenceIds: withoutId(model.endingSilenceIds, silenceId) } + return Result.match(result, { + onSuccess: (): Return => ({ + ...refreshSilences(settled, context), + outMessage: toast("success", "Silence ended"), + }), + onFailure: (failure): Return => ({ + model: settled, + outMessage: errorToast("Couldn't end silence", failure), + }), + }) + }, ClickedConfirmDeleteMonitor: () => confirmDeleteMonitor(model), GotDeleteMonitorDialogMessage: ({ message: child }) => model.deletingMonitorId !== null diff --git a/apps/web/src/page/alerts/hub/view-silences.ts b/apps/web/src/page/alerts/hub/view-silences.ts new file mode 100644 index 0000000000..a847bc7576 --- /dev/null +++ b/apps/web/src/page/alerts/hub/view-silences.ts @@ -0,0 +1,175 @@ +import type { V2AlertSilence } from "@maple/domain/http/v2" +import { Option, Array as Arr } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { badge } from "@maple/views/ui/display" +import { button } from "@maple/views/ui/button" +import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "@maple/views/ui/empty" +import { emptyActions } from "@maple/views/ui/signal-empty-state" +import { errorState } from "@maple/views/ui/error-state" +import { icon } from "@maple/views/ui/icon" +import { listRow } from "@maple/views/ui/list-row" +import { panel } from "@maple/views/ui/panel" +import { skeleton } from "@maple/views/ui/skeleton" +import { vstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" +import { sectionHeading } from "../../kit/section-heading" +import { + endsAtMs, + formatSilenceTime, + silenceScopeParts, + silenceStatusAt, + startsAtMs, +} from "../shared/silences" +import { Message } from "./message" +import { canManage, type Model } from "./model" + +// The Silences tab: what is silenced now, what is scheduled, and what ended recently. + +type H = HtmlBuilder + +/** Ended silences stay listed for a week, so a recent maintenance window is still visible. */ +const ENDED_VISIBLE_MS = 7 * 24 * 60 * 60_000 + +const silencesEmptyState = (h: H, canEdit: boolean): Html => + empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "BellSlashIcon", { size: 18 })]), + emptyTitle(h, ["No silences"]), + emptyDescription(h, [ + "Silence a rule, a service or an environment during a deploy or a planned migration. Rules keep checking and incidents still open, but no one is paged.", + ]), + ]), + ...(canEdit + ? [ + emptyActions(h, [ + button(h, { size: "sm", attributes: [h.OnClick(Message.ClickedNewSilence())] }, [ + icon(h, "PlusIcon"), + "New silence", + ]), + ]), + ] + : []), + ], + "py-12", + ) + +const windowText = (silence: V2AlertSilence, nowMs: number, timeZone: string): string => { + const status = silenceStatusAt(silence, nowMs) + const start = formatSilenceTime(startsAtMs(silence), nowMs, timeZone) + const end = formatSilenceTime(endsAtMs(silence), nowMs, timeZone) + return status === "active" + ? `Until ${end}` + : status === "scheduled" + ? `${start} to ${end}` + : `Ended ${end}` +} + +const silenceRow = ( + h: H, + model: Model, + silence: V2AlertSilence, + options: Readonly<{ nowMs: number; timeZone: string; ruleName: (id: string) => string }>, +): Html => { + const status = silenceStatusAt(silence, options.nowMs) + const isEnding = model.endingSilenceIds.includes(silence.id) + return listRow(h, { + divided: true, + leading: icon(h, "BellSlashIcon", { size: 16 }), + title: silence.reason, + meta: Arr.join( + [ + windowText(silence, options.nowMs, options.timeZone), + ...silenceScopeParts(silence.matchers, options.ruleName), + ], + " · ", + ), + trailing: + status !== "ended" && canManage(model) + ? [ + button( + h, + { + size: "sm", + variant: "outline", + isDisabled: isEnding, + attributes: [h.OnClick(Message.ClickedEndSilence({ silenceId: silence.id }))], + }, + [status === "scheduled" ? "Cancel" : "End now"], + ), + ] + : status === "ended" + ? [badge(h, { variant: "outline" }, ["Ended"])] + : [], + }) +} + +const section = ( + h: H, + model: Model, + title: string, + silences: ReadonlyArray, + options: Readonly<{ nowMs: number; timeZone: string; ruleName: (id: string) => string }>, +): ReadonlyArray => + silences.length === 0 + ? [] + : [ + vstack(h, { gap: "2" }, [ + sectionHeading(h, { title, count: String(silences.length) }), + panel( + h, + {}, + Arr.map(silences, (silence) => silenceRow(h, model, silence, options)), + ), + ]), + ] + +const loaded = ( + h: H, + model: Model, + silences: ReadonlyArray, + nowMs: number, + timeZone: string, +): Html => { + const rules = model.rules ?? [] + const ruleName = (id: string) => + Option.getOrElse( + Option.map( + Arr.findFirst(rules, (rule) => rule.id === id), + (rule) => rule.name, + ), + () => "deleted rule", + ) + const byStatus = (status: V2AlertSilence["status"]) => + Arr.filter(silences, (silence) => silenceStatusAt(silence, nowMs) === status) + const ended = Arr.filter(byStatus("ended"), (silence) => endsAtMs(silence) > nowMs - ENDED_VISIBLE_MS) + const active = byStatus("active") + const scheduled = byStatus("scheduled") + if (active.length + scheduled.length + ended.length === 0) return silencesEmptyState(h, canManage(model)) + const options = { nowMs, timeZone, ruleName } + return vstack(h, { gap: "6" }, [ + ...section(h, model, "Active", active, options), + ...section(h, model, "Scheduled", scheduled, options), + ...section(h, model, "Ended in the last 7 days", ended, options), + ...(active.length === 0 + ? [typography(h, { size: "xs", tone: "muted" }, ["Nothing is silenced right now"])] + : []), + ]) +} + +export const silencesTab = (h: H, model: Model, nowMs: number, timeZone: string): Html => { + const data = model.silences.data + if (AsyncData.isFailure(data)) + return errorState(h, { + failure: data.error, + title: "Couldn't load silences", + onRetry: Message.ClickedRetrySilences(), + className: "py-12", + }) + return Option.match(AsyncData.getData(data), { + onNone: () => vstack(h, { gap: "4" }, [skeleton(h, "h-6 w-40"), skeleton(h, "h-40 w-full")]), + onSome: (silences) => loaded(h, model, silences, nowMs, timeZone), + }) +} diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index 6c1cd9174a..23db155d81 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -21,6 +21,9 @@ import { alertsEmptyState } from "./view-empty" import { inboxTab } from "./view-inbox" import { routingTab } from "./view-routing" import * as DestinationDialogView from "../destination/view" +import * as SilenceDialogView from "../silence-dialog/view" +import { silenceStatusAt } from "../shared/silences" +import { silencesTab } from "./view-silences" import { rulesTab } from "./view-rules" import { deleteMonitorDialog, uptimeTab } from "./view-uptime" import { hstack, vstack } from "@maple/views/ui/stack" @@ -33,6 +36,7 @@ const ALERTS_TABS: ReadonlyArray<{ value: AlertsTab; label: string }> = [ { value: "inbox", label: "Inbox" }, { value: "rules", label: "Rules" }, { value: "uptime", label: "Uptime" }, + { value: "silences", label: "Silences" }, { value: "routing", label: "Routing" }, ] @@ -45,6 +49,11 @@ const tabBar = ( active: AlertsTab, ): Html => { const monitorCount = Option.map(AsyncData.getData(model.monitors.data), (monitors) => monitors.length) + const activeSilenceCount = Option.map( + AsyncData.getData(model.silences.data), + (silences) => + Arr.filter(silences, (silence) => silenceStatusAt(silence, model.nowMs ?? 0) === "active").length, + ) return linkTabStrip( h, "Alerts sections", @@ -52,15 +61,17 @@ const tabBar = ( const count = tab.value === "uptime" ? Option.getOrUndefined(monitorCount) - : Option.match(overview, { - onNone: () => undefined, - onSome: (data) => - tab.value === "inbox" - ? data.openIncidents.length - : tab.value === "rules" - ? data.rules.length - : undefined, - }) + : tab.value === "silences" + ? Option.getOrUndefined(activeSilenceCount) + : Option.match(overview, { + onNone: () => undefined, + onSome: (data) => + tab.value === "inbox" + ? data.openIncidents.length + : tab.value === "rules" + ? data.rules.length + : undefined, + }) return { key: tab.value, href: alertsHref({ ...model.search, tab: tab.value }), @@ -161,6 +172,19 @@ const newRuleButton = (h: H, model: Model): Html => // Destinations are a Routing concern, so "New destination" only shows on that tab. const headerActions = (h: H, model: Model, tab: AlertsTab): Html => hstack(h, { gap: "2" }, [ + ...(canManage(model) && tab === "silences" + ? [ + button( + h, + { + size: "sm", + variant: "outline", + attributes: [h.OnClick(Message.ClickedNewSilence())], + }, + [icon(h, "BellSlashIcon"), "New silence"], + ), + ] + : []), ...(canManage(model) && tab === "routing" ? [ button( @@ -277,7 +301,9 @@ export const view = Submodel.defineView((model, }) : tab === "uptime" ? uptimeTab(h, model, model.nowMs ?? 0, inputs.context.pinnedNamespace) - : hubBody(h, model, overview, tab), + : tab === "silences" + ? silencesTab(h, model, model.nowMs ?? 0, inputs.context.timeZone) + : hubBody(h, model, overview, tab), ], ), deleteDialog(h, model), @@ -289,6 +315,13 @@ export const view = Submodel.defineView((model, viewInputs: { id: "alerts-destination" }, toParentMessage: (message) => Message.GotDestinationDialogMessage({ message }), }), + h.submodel({ + slotId: "alerts-silence", + model: model.silenceDialog, + view: SilenceDialogView.view, + viewInputs: { id: "alerts-silence" }, + toParentMessage: (message) => Message.GotSilenceDialogMessage({ message }), + }), ], ) }) diff --git a/apps/web/src/page/alerts/incident/command.ts b/apps/web/src/page/alerts/incident/command.ts index 2e03a05593..ac695c0a13 100644 --- a/apps/web/src/page/alerts/incident/command.ts +++ b/apps/web/src/page/alerts/incident/command.ts @@ -5,6 +5,7 @@ import { Command } from "foldkit" import { toLoadFailure } from "@maple/views/data/load-failure" import { MapleApiV2Client } from "../../../platform/api-clients" import { listDeliveries } from "../shared/commands" +import { listSilences } from "../shared/silences" import { fetchCheckSummary } from "../shared/check-summary" import { fetchPreview } from "../shared/preview" import { Message } from "./message" @@ -77,3 +78,13 @@ export const CreateInvestigation = Command.define("CreateAlertInvestigation", { Effect.map((result) => Message.CompletedCreateInvestigation({ result })), ), }) + +export const FetchIncidentSilences = Command.define("FetchAlertIncidentSilences", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchSilences], + execute: ({ key }) => + listSilences.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchSilences({ key, result })), + ), +}) diff --git a/apps/web/src/page/alerts/incident/message.ts b/apps/web/src/page/alerts/incident/message.ts index 9aff3da93a..780af2aae6 100644 --- a/apps/web/src/page/alerts/incident/message.ts +++ b/apps/web/src/page/alerts/incident/message.ts @@ -1,5 +1,5 @@ import { AlertDeliveryEventDocument, AlertRulePreviewResponse } from "@maple/domain/http" -import { V2Investigation } from "@maple/domain/http/v2" +import { V2AlertSilence, V2Investigation } from "@maple/domain/http/v2" import { AlertIncidentRowSchema, AlertRuleRowSchema, @@ -12,6 +12,7 @@ import { ThemeColors } from "@maple/views/charts/theme-colors" import { LoadFailure } from "@maple/views/data/load-failure" import { CheckSummary } from "../shared/check-summary" import { PreviewFailure } from "../shared/preview" +import * as SilenceDialog from "../silence-dialog/dialog" const rows = (row: S) => ({ orgId: Schema.String, rows: Schema.Array(row) }) @@ -39,5 +40,11 @@ export const Message = defineMessageUnion({ FailedLoadChartRuntime: { reason: Schema.String }, ResolvedThemeColors: { colors: ThemeColors }, GotChartMessage: { message: Chart.Message }, + CompletedFetchSilences: { + key: Schema.String, + result: Schema.Result(Schema.Array(V2AlertSilence), LoadFailure), + }, + ClickedSilence: {}, + GotSilenceDialogMessage: { message: SilenceDialog.Message }, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/incident/model.ts b/apps/web/src/page/alerts/incident/model.ts index e8d0dadd7d..72011a2974 100644 --- a/apps/web/src/page/alerts/incident/model.ts +++ b/apps/web/src/page/alerts/incident/model.ts @@ -1,5 +1,5 @@ import { AlertDeliveryEventDocument } from "@maple/domain/http" -import { V2Investigation } from "@maple/domain/http/v2" +import { V2AlertSilence, V2Investigation } from "@maple/domain/http/v2" import { AlertIncidentRowSchema, AlertRuleRowSchema, @@ -13,6 +13,7 @@ import { KeyedSchema } from "@maple/views/data/keyed" import { LoadFailure } from "@maple/views/data/load-failure" import { CheckSummary } from "../shared/check-summary" import { PreviewSlot } from "../shared/preview" +import * as SilenceDialog from "../silence-dialog/dialog" // `/alerts/incidents/$incidentId`. @@ -35,5 +36,8 @@ export const Model = Schema.Struct({ chart: Chart.Model, chartRuntime: ChartRuntime, themeColors: Schema.NullOr(ThemeColors), + /** `alertSilences.list`: which silence, if any, holds back this incident's notifications. */ + silences: KeyedSchema(Schema.Array(V2AlertSilence), LoadFailure), + silenceDialog: SilenceDialog.Model, }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/alerts/incident/update.ts b/apps/web/src/page/alerts/incident/update.ts index dbd17b7557..0ce12dbbd3 100644 --- a/apps/web/src/page/alerts/incident/update.ts +++ b/apps/web/src/page/alerts/incident/update.ts @@ -21,41 +21,116 @@ import { FetchIncidentDeliveries, FetchIncidentInvestigation, FetchIncidentPreview, + FetchIncidentSilences, ReadIncidentClock, } from "./command" import { incidentViewOf, minuteOf, ruleStatesOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" +import * as SilenceDialog from "../silence-dialog/dialog" +import { matchersForRule } from "../shared/silences" type Return = PageReturn type Commands = ReadonlyArray> type IncidentRoute = Extract -export const init = (route: IncidentRoute, _context: PageContext): Return => ({ - model: { - incidentId: route.incidentId, - nowMs: null, - incidents: null, - rules: null, - states: null, - preview: Keyed.idle(), - checks: Keyed.idle(), - deliveries: Keyed.idle(), - investigation: Keyed.idle(), - isStartingInvestigation: false, - chart: Chart.init(), - chartRuntime: { _tag: "Loading" }, - themeColors: null, - }, - commands: [ - ReadIncidentClock(), - Command.mapMessage(LoadChartRuntime(), (message) => - message._tag === "CompletedLoadChartRuntime" - ? Message.CompletedLoadChartRuntime() - : Message.FailedLoadChartRuntime({ reason: message.reason }), - ), - ], -}) +const silencesKey = (context: PageContext) => + requestKey(context.orgId, "alertSilences.list", { query: { limit: 100 } }) + +const requestSilences = (model: Model, context: PageContext, mode: Keyed.ReadMode): Return => + Option.match(Keyed.requestFor(model.silences, silencesKey(context), mode), { + onNone: () => ({ model }), + onSome: (silences) => ({ + model: { ...model, silences }, + commands: [FetchIncidentSilences({ key: silences.key })], + }), + }) + +/** The silence dialog's outcome: a toast, and a re-read so the header shows the new silence. */ +const liftSilenceDialog = ( + model: Model, + context: PageContext, + step: ReturnType, +): Return => { + const next = { ...model, silenceDialog: step.model } + const commands = Command.mapMessages(step.commands ?? [], (message) => + Message.GotSilenceDialogMessage({ message }), + ) + if (step.outMessage === undefined) return { model: next, commands } + return SilenceDialog.OutMessage.match(step.outMessage, { + RequestedToast: ({ type, title, description }) => ({ + model: next, + commands, + outMessage: PageOutMessage.RequestedToast({ type, title, description }), + }), + CreatedSilence: () => { + const refreshed = requestSilences(next, context, "Supersede") + return { + model: refreshed.model, + commands: [...commands, ...(refreshed.commands ?? []), ReadIncidentClock()], + outMessage: PageOutMessage.RequestedToast({ + type: "success", + title: "Alerts silenced", + description: "", + }), + } + }, + }) +} + +/** Silence this incident's rule, or only its group when the rule is grouped by service. */ +const openSilence = (model: Model, context: PageContext): Return => + Option.match(incidentViewOf(model), { + onNone: () => ({ model }), + onSome: ({ incident, rule }) => { + const group = incidentGroup(incident) + const name = rule?.name ?? incident.ruleName + const target = + rule === null + ? { label: name, matchers: { rule_ids: [incident.ruleId] } } + : { + label: group === null ? name : `${name} · ${group}`, + matchers: matchersForRule(rule, group), + } + return liftSilenceDialog( + model, + context, + SilenceDialog.openFor(model.silenceDialog, target, context.timeZone), + ) + }, + }) + +export const init = (route: IncidentRoute, context: PageContext): Return => { + const initial: Return = { + model: { + incidentId: route.incidentId, + nowMs: null, + incidents: null, + rules: null, + states: null, + preview: Keyed.idle(), + checks: Keyed.idle(), + deliveries: Keyed.idle(), + investigation: Keyed.idle(), + isStartingInvestigation: false, + chart: Chart.init(), + chartRuntime: { _tag: "Loading" }, + themeColors: null, + silences: Keyed.idle(), + silenceDialog: SilenceDialog.init("alert-incident-silence"), + }, + commands: [ + ReadIncidentClock(), + Command.mapMessage(LoadChartRuntime(), (message) => + message._tag === "CompletedLoadChartRuntime" + ? Message.CompletedLoadChartRuntime() + : Message.FailedLoadChartRuntime({ reason: message.reason }), + ), + ], + } + const silences = requestSilences(initial.model, context, "Load") + return { model: silences.model, commands: [...(initial.commands ?? []), ...(silences.commands ?? [])] } +} /** An atom never refetches a failed read on its own: a same-key `Load` leaves the slot alone. */ const sameKey = (slot: Keyed.Keyed, key: string) => slot.key === key @@ -278,6 +353,12 @@ export const update = (model: Model, message: Message, context: PageContext): Re model: { ...model, chartRuntime: { _tag: "Failed", reason } }, }), ResolvedThemeColors: ({ colors }) => ({ model: { ...model, themeColors: colors } }), + CompletedFetchSilences: ({ key, result }) => ({ + model: { ...model, silences: Keyed.accept(model.silences, key, result) }, + }), + ClickedSilence: () => openSilence(model, context), + GotSilenceDialogMessage: ({ message: child }) => + liftSilenceDialog(model, context, SilenceDialog.update(model.silenceDialog, child)), GotChartMessage: ({ message: child }) => { const next = Chart.update(model.chart, child) return { diff --git a/apps/web/src/page/alerts/incident/view.ts b/apps/web/src/page/alerts/incident/view.ts index 112d0c880d..5a5bd0b3ce 100644 --- a/apps/web/src/page/alerts/incident/view.ts +++ b/apps/web/src/page/alerts/incident/view.ts @@ -48,6 +48,10 @@ import { groupPreviewOf, type IncidentView, incidentViewOf, ruleStatesOf } from import { Message } from "./message" import type { Model } from "./model" import { incidentRail } from "./view-rail" +import { activeSilenceFor } from "../shared/silences" +import { silenceBadge, silenceButton } from "../shared/silence-view" +import * as SilenceDialogView from "../silence-dialog/view" +import { hstack } from "@maple/views/ui/stack" import { incidentTimeline } from "./view-timeline" import { typography } from "@maple/views/ui/typography" @@ -71,6 +75,7 @@ export const headerTitle = ( rule: AlertRuleDocument | null, now: number, timeZone: string, + badges: ReadonlyArray = [], ) => { const group = incidentGroup(incident) const isUptime = incident.signalType === "uptime" @@ -102,6 +107,7 @@ export const headerTitle = ( [h.Class("flex flex-wrap items-center gap-2.5")], [ severityBadge(h, incident.severity), + ...badges, typography(h, { size: "xs", tone: "muted" }, [statusLine(incident, now, timeZone)]), ], ), @@ -112,6 +118,12 @@ export const headerTitle = ( } const headerAction = (h: H, model: Model, investigation: V2Investigation | null): Html => + hstack(h, { gap: "2" }, [ + silenceButton(h, Message.ClickedSilence()), + investigateAction(h, model, investigation), + ]) + +const investigateAction = (h: H, model: Model, investigation: V2Investigation | null): Html => investigation !== null ? linkButton( h, @@ -303,7 +315,28 @@ export const view = Submodel.defineView((model, return dashboardPage( h, { - titleContent: headerTitle(h, view.incident, view.rule, now, timeZone), + titleContent: headerTitle( + h, + view.incident, + view.rule, + now, + timeZone, + view.incident.status === "open" + ? Option.toArray( + Option.map( + Option.flatMap(Option.fromNullishOr(view.rule), (rule) => + activeSilenceFor( + AsyncData.getOrElse(model.silences.data, () => []), + rule, + view.incident.groupKey, + now, + ), + ), + (silence) => silenceBadge(h, silence, now, timeZone), + ), + ) + : [], + ), actions: headerAction( h, model, @@ -314,7 +347,19 @@ export const view = Submodel.defineView((model, ) }, }) - return h.div([h.Class("contents"), h.OnMount(ResolveThemeColors({ tokens: RULE_CHART_TOKENS }))], [page]) + return h.div( + [h.Class("contents"), h.OnMount(ResolveThemeColors({ tokens: RULE_CHART_TOKENS }))], + [ + page, + h.submodel({ + slotId: "alert-incident-silence", + model: model.silenceDialog, + view: SilenceDialogView.view, + viewInputs: { id: "alert-incident-silence" }, + toParentMessage: (message) => Message.GotSilenceDialogMessage({ message }), + }), + ], + ) }) /** `DashboardPage`'s breadcrumbs per state. */ diff --git a/apps/web/src/page/alerts/rule/command.ts b/apps/web/src/page/alerts/rule/command.ts index 3b31bfcf6f..ecaec64e8f 100644 --- a/apps/web/src/page/alerts/rule/command.ts +++ b/apps/web/src/page/alerts/rule/command.ts @@ -7,6 +7,7 @@ import { toLoadFailure } from "@maple/views/data/load-failure" import { resolveWindow } from "../../../data/time-range" import { MapleApiV2Client } from "../../../platform/api-clients" import { listDeliveries, setRuleEnabled } from "../shared/commands" +import { listSilences } from "../shared/silences" import { fetchCheckSummary } from "../shared/check-summary" import { fetchPreview } from "../shared/preview" import { CheckStatusFilter } from "./model" @@ -166,3 +167,13 @@ export const OpenRuleInvestigation = Command.define("OpenAlertRuleInvestigation" Effect.map((result) => Message.CompletedOpenInvestigation({ result })), ), }) + +export const FetchRuleSilences = Command.define("FetchAlertRuleSilences", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchSilences], + execute: ({ key }) => + listSilences.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchSilences({ key, result })), + ), +}) diff --git a/apps/web/src/page/alerts/rule/message.ts b/apps/web/src/page/alerts/rule/message.ts index 9a455c8273..33b1354789 100644 --- a/apps/web/src/page/alerts/rule/message.ts +++ b/apps/web/src/page/alerts/rule/message.ts @@ -1,4 +1,9 @@ -import { AlertCheckDocument, AlertDeliveryEventDocument, AlertIncidentId, AlertRulePreviewResponse } from "@maple/domain/http" +import { + AlertCheckDocument, + AlertDeliveryEventDocument, + AlertIncidentId, + AlertRulePreviewResponse, +} from "@maple/domain/http" import { AlertDestinationRowSchema, AlertIncidentRowSchema, @@ -10,6 +15,8 @@ import { defineMessageUnion } from "foldkit/message" import * as Chart from "@maple/views/charts/chart" import { ThemeColors } from "@maple/views/charts/theme-colors" import { LoadFailure } from "@maple/views/data/load-failure" +import { V2AlertSilence } from "@maple/domain/http/v2" +import * as SilenceDialog from "../silence-dialog/dialog" import { TimeWindow } from "../../../data/time-range" import * as Menu from "@maple/views/ui/menu" import * as TimeRangePicker from "../../kit/time-range-picker" @@ -33,7 +40,10 @@ export const Message = defineMessageUnion({ result: Schema.Result(Schema.Array(AlertDeliveryEventDocument), LoadFailure), }, CompletedFetchChecks: { key: Schema.String, result: Schema.Result(ChecksResponse, LoadFailure) }, - CompletedFetchPreview: { key: Schema.String, result: Schema.Result(AlertRulePreviewResponse, PreviewFailure) }, + CompletedFetchPreview: { + key: Schema.String, + result: Schema.Result(AlertRulePreviewResponse, PreviewFailure), + }, ClickedStateFilter: { filter: StateFilter }, ClickedCheckStatusFilter: { filter: CheckStatusFilter }, ClickedSignalSource: { source: SignalSource }, @@ -48,7 +58,10 @@ export const Message = defineMessageUnion({ CompletedLoadMoreChecks: { generation: Schema.Number, result: Schema.Result( - Schema.Struct({ checks: Schema.Array(AlertCheckDocument), nextCursor: Schema.NullOr(Schema.String) }), + Schema.Struct({ + checks: Schema.Array(AlertCheckDocument), + nextCursor: Schema.NullOr(Schema.String), + }), LoadFailure, ), }, @@ -71,5 +84,11 @@ export const Message = defineMessageUnion({ FailedLoadChartRuntime: { reason: Schema.String }, ResolvedThemeColors: { colors: ThemeColors }, GotChartMessage: { message: Chart.Message }, + CompletedFetchSilences: { + key: Schema.String, + result: Schema.Result(Schema.Array(V2AlertSilence), LoadFailure), + }, + ClickedSilence: {}, + GotSilenceDialogMessage: { message: SilenceDialog.Message }, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/rule/model.ts b/apps/web/src/page/alerts/rule/model.ts index c37a563c38..a570b3023b 100644 --- a/apps/web/src/page/alerts/rule/model.ts +++ b/apps/web/src/page/alerts/rule/model.ts @@ -13,6 +13,8 @@ import { KeyedSchema } from "@maple/views/data/keyed" import { LoadFailure } from "@maple/views/data/load-failure" import { TimeWindow } from "../../../data/time-range" import { AlertRuleSearch } from "../../../route/search" +import { V2AlertSilence } from "@maple/domain/http/v2" +import * as SilenceDialog from "../silence-dialog/dialog" import * as Menu from "@maple/views/ui/menu" import * as TimeRangePicker from "../../kit/time-range-picker" import { CheckSummary } from "../shared/check-summary" @@ -88,5 +90,8 @@ export const Model = Schema.Struct({ chart: Chart.Model, chartRuntime: ChartRuntime, themeColors: Schema.NullOr(ThemeColors), + /** `alertSilences.list`: which silence, if any, holds back this rule's notifications. */ + silences: KeyedSchema(Schema.Array(V2AlertSilence), LoadFailure), + silenceDialog: SilenceDialog.Model, }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/alerts/rule/update.ts b/apps/web/src/page/alerts/rule/update.ts index 670f944545..3bc1b0ed0b 100644 --- a/apps/web/src/page/alerts/rule/update.ts +++ b/apps/web/src/page/alerts/rule/update.ts @@ -26,6 +26,7 @@ import { ReadDiagnosisClock, ResolveRuleWindow, ToggleRuleEnabled, + FetchRuleSilences, } from "./command" import { checksPageOf, @@ -38,6 +39,67 @@ import { } from "./derive" import { Message, type RequestMode } from "./message" import type { Model } from "./model" +import * as SilenceDialog from "../silence-dialog/dialog" +import { matchersForRule } from "../shared/silences" + +const silencesKey = (context: PageContext) => + requestKey(context.orgId, "alertSilences.list", { query: { limit: 100 } }) + +const requestSilences = (model: Model, context: PageContext, mode: Keyed.ReadMode): Return => + Option.match(Keyed.requestFor(model.silences, silencesKey(context), mode), { + onNone: () => ({ model }), + onSome: (silences) => ({ + model: { ...model, silences }, + commands: [FetchRuleSilences({ key: silences.key })], + }), + }) + +/** The silence dialog's outcome: a toast, and a re-read so the header shows the new silence. */ +const liftSilenceDialog = ( + model: Model, + context: PageContext, + step: ReturnType, +): Return => { + const next = { ...model, silenceDialog: step.model } + const commands = Command.mapMessages(step.commands ?? [], (message) => + Message.GotSilenceDialogMessage({ message }), + ) + if (step.outMessage === undefined) return { model: next, commands } + return SilenceDialog.OutMessage.match(step.outMessage, { + RequestedToast: ({ type, title, description }) => ({ + model: next, + commands, + outMessage: PageOutMessage.RequestedToast({ type, title, description }), + }), + CreatedSilence: () => { + const refreshed = requestSilences(next, context, "Supersede") + return { + model: refreshed.model, + commands: [...commands, ...(refreshed.commands ?? []), ReadDiagnosisClock()], + outMessage: PageOutMessage.RequestedToast({ + type: "success", + title: "Alerts silenced", + description: "", + }), + } + }, + }) +} + +const openSilence = (model: Model, context: PageContext): Return => { + const rule = ruleOf(model) + return rule === null + ? { model } + : liftSilenceDialog( + model, + context, + SilenceDialog.openFor( + model.silenceDialog, + { label: rule.name, matchers: matchersForRule(rule, null) }, + context.timeZone, + ), + ) +} type Return = PageReturn type Commands = ReadonlyArray> @@ -150,11 +212,15 @@ export const init = (route: RuleRoute, context: PageContext): Return => { chart: Chart.init(), chartRuntime: { _tag: "Loading" }, themeColors: null, + silences: Keyed.idle(), + silenceDialog: SilenceDialog.init("alert-rule-silence"), } - const followed = followData(model, context, "Load") + const silences = requestSilences(model, context, "Load") + const followed = followData(silences.model, context, "Load") return { model: followed.model, commands: [ + ...(silences.commands ?? []), resolveCommand(model, context, "Load"), ReadDiagnosisClock(), ...(followed.commands ?? []), @@ -495,6 +561,12 @@ export const update = (model: Model, message: Message, context: PageContext): Re model: { ...model, chartRuntime: { _tag: "Failed", reason } }, }), ResolvedThemeColors: ({ colors }) => ({ model: { ...model, themeColors: colors } }), + CompletedFetchSilences: ({ key, result }) => ({ + model: { ...model, silences: Keyed.accept(model.silences, key, result) }, + }), + ClickedSilence: () => openSilence(model, context), + GotSilenceDialogMessage: ({ message: child }) => + liftSilenceDialog(model, context, SilenceDialog.update(model.silenceDialog, child)), GotChartMessage: ({ message: child }) => { const next = Chart.update(model.chart, child) return { diff --git a/apps/web/src/page/alerts/rule/view.ts b/apps/web/src/page/alerts/rule/view.ts index c6532a0e1b..b9ea9e6233 100644 --- a/apps/web/src/page/alerts/rule/view.ts +++ b/apps/web/src/page/alerts/rule/view.ts @@ -1,6 +1,6 @@ -import type { AlertRuleDocument } from "@maple/domain/http" +import type { AlertIncidentDocument, AlertRuleDocument } from "@maple/domain/http" import { comparatorLabels, formatSignalValue, signalLabels } from "@maple/web-data/alerts/form-utils" -import { Submodel } from "foldkit" +import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { ResolveThemeColors } from "@maple/views/charts/theme-colors" import { linkButton } from "@maple/views/ui/button" @@ -24,7 +24,10 @@ import { historyTab } from "./view-history" import { overviewTab } from "./view-overview" import { typography } from "@maple/views/ui/typography" import { hstack } from "@maple/views/ui/stack" -import { Array as Arr } from "effect" +import { Option, Array as Arr } from "effect" +import { activeSilenceFor } from "../shared/silences" +import { silenceBadge, silenceButton } from "../shared/silence-view" +import * as SilenceDialogView from "../silence-dialog/view" // The rule detail page through `ResultPage` and `DetailHeader`. @@ -49,9 +52,27 @@ export const ruleSubtitle = (rule: AlertRuleDocument): string => { const toTimePicker = (message: TimeRangePicker.Message) => Message.GotTimePickerMessage({ message }) /** `DetailHeader` (kind eyebrow, title, meta row) with the time controls and Edit rule. */ -const header = (h: H, model: Model, rule: AlertRuleDocument) => { +/** The silence over the whole rule, or else over one of its open incidents' groups. */ +const ruleSilence = ( + model: Model, + rule: AlertRuleDocument, + openIncidents: ReadonlyArray, +) => { + const silences = AsyncData.getOrElse(model.silences.data, () => []) + return Option.orElse(activeSilenceFor(silences, rule, null, model.nowMs), () => + Arr.findFirst( + Arr.map(openIncidents, (incident) => + activeSilenceFor(silences, rule, incident.groupKey, model.nowMs), + ), + Option.isSome, + ).pipe(Option.flatten), + ) +} + +const header = (h: H, model: Model, rule: AlertRuleDocument, timeZone: string) => { const openIncidents = Arr.filter(ruleIncidentsOf(model), (i) => i.status === "open") const isFiring = openIncidents.length > 0 + const silence = ruleSilence(model, rule, openIncidents) return { titleContent: h.div( [h.Class("min-w-0 space-y-2.5")], @@ -68,6 +89,9 @@ const header = (h: H, model: Model, rule: AlertRuleDocument) => { Arr.every(openIncidents, (i) => i.holdReason != null) ? "held" : "firing", ) : alertStatusBadge(h, rule.enabled ? "ok" : "disabled"), + ...Option.toArray( + Option.map(silence, (active) => silenceBadge(h, active, model.nowMs, timeZone)), + ), typography(h, { size: "sm", tone: "muted" }, [ruleSubtitle(rule)]), ]), ], @@ -95,6 +119,7 @@ const header = (h: H, model: Model, rule: AlertRuleDocument) => { }, }), }), + silenceButton(h, Message.ClickedSilence()), linkButton(h, { variant: "outline", size: "sm", href: createHref({ ruleId: rule.id }) }, [ icon(h, "PencilIcon"), "Edit rule", @@ -147,14 +172,26 @@ export const view = Submodel.defineView((model, back: { href: alertsHref({}), label: "Back to rules" }, }), ]) - : dashboardPage(h, { ...header(h, model, rule), tabs: tabStrip(h, model) }, [ + : dashboardPage(h, { ...header(h, model, rule, timeZone), tabs: tabStrip(h, model) }, [ activeTabOf(model) === "overview" ? overviewTab(h, model, rule, timeZone) : model.incidents === null ? historyLoading(h) : historyTab(h, model, rule, ruleIncidentsOf(model), timeZone, model.nowMs), ]) - return h.div([h.Class("contents"), h.OnMount(ResolveThemeColors({ tokens: RULE_CHART_TOKENS }))], [page]) + return h.div( + [h.Class("contents"), h.OnMount(ResolveThemeColors({ tokens: RULE_CHART_TOKENS }))], + [ + page, + h.submodel({ + slotId: "alert-rule-silence", + model: model.silenceDialog, + view: SilenceDialogView.view, + viewInputs: { id: "alert-rule-silence" }, + toParentMessage: (message) => Message.GotSilenceDialogMessage({ message }), + }), + ], + ) }) export const breadcrumbsOf = (model: Model) => { diff --git a/apps/web/src/page/alerts/shared/silence-view.ts b/apps/web/src/page/alerts/shared/silence-view.ts new file mode 100644 index 0000000000..4e9fb56674 --- /dev/null +++ b/apps/web/src/page/alerts/shared/silence-view.ts @@ -0,0 +1,25 @@ +import type { V2AlertSilence } from "@maple/domain/http/v2" +import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "@maple/views/ui/button" +import { badge } from "@maple/views/ui/display" +import { icon } from "@maple/views/ui/icon" +import { endsAtMs, formatSilenceTime } from "./silences" + +/** "Silenced until 15:00": the silence holding back a rule's or an incident's notifications. */ +export const silenceBadge = ( + h: HtmlBuilder, + silence: V2AlertSilence, + nowMs: number, + timeZone: string, +): Html => + badge(h, { variant: "outline", attributes: [h.Title(silence.reason)] }, [ + icon(h, "BellSlashIcon"), + `Silenced until ${formatSilenceTime(endsAtMs(silence), nowMs, timeZone)}`, + ]) + +/** The header's "Silence" action, which opens the silence dialog scoped to the page's rule or incident. */ +export const silenceButton = (h: HtmlBuilder, onClick: M): Html => + button(h, { variant: "outline", size: "sm", attributes: [h.OnClick(onClick)] }, [ + icon(h, "BellSlashIcon"), + "Silence", + ]) diff --git a/apps/web/src/page/alerts/shared/silences.test.ts b/apps/web/src/page/alerts/shared/silences.test.ts new file mode 100644 index 0000000000..f159f15474 --- /dev/null +++ b/apps/web/src/page/alerts/shared/silences.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest" +import { Option, Schema } from "effect" +import { AlertRuleId, AlertSilenceId } from "@maple/domain/http" +import type { V2AlertSilence } from "@maple/domain/http/v2" +import { activeSilenceFor, matchersForRule } from "./silences" + +const ruleId = Schema.decodeSync(AlertRuleId)("11111111-1111-4111-8111-111111111111") +const asSilenceId = Schema.decodeSync(AlertSilenceId) + +const NOW = Date.UTC(2026, 10, 6, 12, 0) +const iso = (ms: number) => new Date(ms).toISOString() + +const silence = ( + id: string, + matchers: V2AlertSilence["matchers"], + startOffsetMin: number, + endOffsetMin: number, +): V2AlertSilence => ({ + id: asSilenceId(id), + object: "alert_silence", + reason: "deploy", + matchers, + status: "active", + starts_at: iso(NOW + startOffsetMin * 60_000), + ends_at: iso(NOW + endOffsetMin * 60_000), + created_by: "user_1", + created_at: iso(NOW), + updated_at: iso(NOW), +}) + +const groupedRule = { + id: ruleId, + tags: [], + serviceNames: [], + environments: [], + groupBy: ["service.name"], +} + +describe("activeSilenceFor", () => { + it("picks the soonest-ending silence in force, from the window not the stored status", () => { + const long = silence("22222222-2222-4222-8222-222222222222", { rule_ids: [ruleId] }, -10, 120) + const short = silence("33333333-3333-4333-8333-333333333333", { rule_ids: [ruleId] }, -10, 30) + const ended = silence("44444444-4444-4444-8444-444444444444", { rule_ids: [ruleId] }, -60, -1) + const scheduled = silence("55555555-5555-4555-8555-555555555555", { rule_ids: [ruleId] }, 5, 10) + expect( + Option.map( + activeSilenceFor([long, ended, scheduled, short], groupedRule, null, NOW), + (s) => s.id, + ), + ).toEqual(Option.some(short.id)) + }) + + it("covers only the silenced group of a rule grouped by service", () => { + const checkout = silence( + "66666666-6666-4666-8666-666666666666", + matchersForRule(groupedRule, "checkout"), + -1, + 60, + ) + expect(Option.isSome(activeSilenceFor([checkout], groupedRule, "checkout", NOW))).toBe(true) + expect(Option.isSome(activeSilenceFor([checkout], groupedRule, "payments", NOW))).toBe(false) + }) +}) diff --git a/apps/web/src/page/alerts/shared/silences.ts b/apps/web/src/page/alerts/shared/silences.ts new file mode 100644 index 0000000000..0407529f10 --- /dev/null +++ b/apps/web/src/page/alerts/shared/silences.ts @@ -0,0 +1,111 @@ +import { + alertSilenceMatches, + type AlertRuleDocument, + type AlertSilenceId, + type AlertSilenceMatchers, +} from "@maple/domain/http" +import type { + V2AlertSilence, + V2AlertSilenceCreateParams, + V2AlertSilenceMatchers, +} from "@maple/domain/http/v2" +import { Effect, Option, Order, Array as Arr } from "effect" +import { formatDateInTimezone, formatTimeInTimezone } from "@maple/web-data/lib/timezone-format" +import { toLoadFailure } from "@maple/views/data/load-failure" +import { MapleApiV2Client } from "../../../platform/api-clients" +import { isoMs } from "./time" + +// Alert silences: the reads and writes the hub, rule and incident pages share, and how a page +// tells which silence covers a rule or an incident. + +/** The org's silences, latest ending first (one page; an org holds at most 200 open ones). */ +export const listSilences = Effect.gen(function* () { + const client = yield* MapleApiV2Client + const response = yield* client.alertSilences.list({ query: { limit: 100 } }) + return response.data +}).pipe(Effect.mapError(toLoadFailure)) + +export const createSilence = (payload: V2AlertSilenceCreateParams) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.alertSilences.create({ payload }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const endSilence = (id: AlertSilenceId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.alertSilences.end({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const startsAtMs = (silence: V2AlertSilence): number => isoMs(silence.starts_at) +export const endsAtMs = (silence: V2AlertSilence): number => isoMs(silence.ends_at) + +/** "15:00" today, "Nov 7, 09:00" on another day, in the viewer's zone. */ +export const formatSilenceTime = (ms: number, nowMs: number, timeZone: string): string => { + const time = formatTimeInTimezone(ms, { timeZone, withSeconds: false }) + return formatDateInTimezone(ms, { timeZone }) === formatDateInTimezone(nowMs, { timeZone }) + ? time + : `${formatDateInTimezone(ms, { timeZone, withYear: false })}, ${time}` +} + +/** Where a silence stands at `nowMs`, from its window rather than the status it was read with. */ +export const silenceStatusAt = (silence: V2AlertSilence, nowMs: number): V2AlertSilence["status"] => + nowMs < startsAtMs(silence) ? "scheduled" : nowMs < endsAtMs(silence) ? "active" : "ended" + +const toDomainMatchers = (matchers: V2AlertSilenceMatchers): AlertSilenceMatchers => ({ + ...(matchers.rule_ids === undefined ? undefined : { ruleIds: matchers.rule_ids }), + ...(matchers.tags === undefined ? undefined : { tags: matchers.tags }), + ...(matchers.services === undefined ? undefined : { services: matchers.services }), + ...(matchers.environments === undefined ? undefined : { environments: matchers.environments }), +}) + +const byEnd = Order.mapInput(Order.Number, endsAtMs) + +/** The silence in force over a rule (and one incident group of it) at `nowMs`; the soonest-ending wins. */ +export const activeSilenceFor = ( + silences: ReadonlyArray, + rule: Pick, + groupKey: string | null, + nowMs: number, +): Option.Option => + Arr.head( + Arr.sort( + Arr.filter( + silences, + (silence) => + silenceStatusAt(silence, nowMs) === "active" && + alertSilenceMatches(toDomainMatchers(silence.matchers), { + ruleId: rule.id, + tags: rule.tags, + serviceNames: rule.serviceNames, + environments: rule.environments, + groupBy: rule.groupBy, + groupKey, + }), + ), + byEnd, + ), + ) + +/** "service checkout · tag db": what a silence covers, for a list row. Rule ids read as their names. */ +export const silenceScopeParts = ( + matchers: V2AlertSilenceMatchers, + ruleName: (id: string) => string, +): ReadonlyArray => [ + ...Arr.map(matchers.rule_ids ?? [], (id) => `rule ${ruleName(id)}`), + ...Arr.map(matchers.services ?? [], (service) => `service ${service}`), + ...Arr.map(matchers.environments ?? [], (environment) => `env ${environment}`), + ...Arr.map(matchers.tags ?? [], (tag) => `tag ${tag}`), +] + +/** The matchers that silence one rule, or just one service group of a rule grouped by service. */ +export const matchersForRule = ( + rule: Pick, + groupKey: string | null, +): V2AlertSilenceMatchers => + groupKey !== null && + rule.groupBy !== null && + rule.groupBy.length === 1 && + rule.groupBy[0] === "service.name" + ? { rule_ids: [rule.id], services: [groupKey] } + : { rule_ids: [rule.id] } diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts new file mode 100644 index 0000000000..cb88eae864 --- /dev/null +++ b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest" +import { Option } from "effect" +import { init, isFormReady, matchersOf, silenceEndMs } from "./dialog" + +// 2026-11-06 14:20 in Berlin (UTC+1). +const NOW = Date.UTC(2026, 10, 6, 13, 20) +const BERLIN = "Europe/Berlin" + +describe("silenceEndMs", () => { + it("ends an hour or four hours from now", () => { + expect(silenceEndMs("1h", "", NOW, BERLIN)).toEqual(Option.some(NOW + 60 * 60_000)) + expect(silenceEndMs("4h", "", NOW, BERLIN)).toEqual(Option.some(NOW + 4 * 60 * 60_000)) + }) + + it("ends at 09:00 tomorrow on the viewer's wall clock", () => { + expect(silenceEndMs("tomorrow", "", NOW, BERLIN)).toEqual(Option.some(Date.UTC(2026, 10, 7, 8, 0))) + }) + + it("reads a custom end in the viewer's zone and refuses one in the past", () => { + expect(silenceEndMs("custom", "2026-11-06T18:30", NOW, BERLIN)).toEqual( + Option.some(Date.UTC(2026, 10, 6, 17, 30)), + ) + expect(silenceEndMs("custom", "2026-11-06T09:00", NOW, BERLIN)).toEqual(Option.none()) + expect(silenceEndMs("custom", "tomorrow", NOW, BERLIN)).toEqual(Option.none()) + }) +}) + +describe("matchersOf", () => { + it("splits typed lists and drops empty fields", () => { + expect( + matchersOf({ + _tag: "Typed", + services: " checkout, payments ,checkout", + tags: "", + environments: "production", + }), + ).toEqual({ services: ["checkout", "payments"], environments: ["production"] }) + }) +}) + +describe("isFormReady", () => { + const typed = (services: string) => ({ _tag: "Typed" as const, services, tags: "", environments: "" }) + + it("needs a reason and something to match", () => { + const model = init("silence") + expect(isFormReady({ ...model, reason: "deploy", scope: typed("") })).toBe(false) + expect(isFormReady({ ...model, reason: " ", scope: typed("checkout") })).toBe(false) + expect(isFormReady({ ...model, reason: "deploy", scope: typed("checkout") })).toBe(true) + }) + + it("needs a readable end for a custom duration", () => { + const model = { + ...init("silence"), + reason: "deploy", + scope: typed("checkout"), + duration: "custom" as const, + } + expect(isFormReady({ ...model, until: "" })).toBe(false) + expect(isFormReady({ ...model, until: "2026-11-07T09:00" })).toBe(true) + }) +}) diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.ts b/apps/web/src/page/alerts/silence-dialog/dialog.ts new file mode 100644 index 0000000000..e5dd813e6d --- /dev/null +++ b/apps/web/src/page/alerts/silence-dialog/dialog.ts @@ -0,0 +1,261 @@ +import { V2AlertSilence, V2AlertSilenceMatchers } from "@maple/domain/http/v2" +import { zonedDateParts, zonedPartsToEpochMs } from "@maple/query-engine/datetime" +import { Clock, DateTime, Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" +import { Command, type Update } from "foldkit" +import { defineMessageUnion } from "foldkit/message" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Dialog from "@maple/views/ui/dialog" +import type { Resources } from "../../../platform/resources" +import { createSilence } from "../shared/silences" + +// "Silence notifications": the dialog the hub, a rule and an incident open, as one Submodel. + +export const SilenceDuration = Schema.Literals(["1h", "4h", "tomorrow", "custom"]) +export type SilenceDuration = typeof SilenceDuration.Type + +export const ScopeField = Schema.Literals(["services", "tags", "environments"]) +export type ScopeField = typeof ScopeField.Type + +/** What the silence covers: fixed by the page that opened it, or typed in (the hub's "New silence"). */ +export const Scope = Schema.Union([ + Schema.TaggedStruct("Fixed", { label: Schema.String, matchers: V2AlertSilenceMatchers }), + Schema.TaggedStruct("Typed", { + services: Schema.String, + tags: Schema.String, + environments: Schema.String, + }), +]) +export type Scope = typeof Scope.Type + +export const Model = Schema.Struct({ + dialog: Dialog.Model, + scope: Scope, + reason: Schema.String, + duration: SilenceDuration, + /** The custom end, as a `datetime-local` input holds it (`2026-11-06T18:30`), in `timeZone`. */ + until: Schema.String, + timeZone: Schema.String, + isSaving: Schema.Boolean, + /** Bumped on every open: a save's result only settles the form it was sent from. */ + openCount: Schema.Number, +}) +export type Model = typeof Model.Type + +export const init = (id: string): Model => ({ + dialog: Dialog.init(id, "dialog"), + scope: { _tag: "Typed", services: "", tags: "", environments: "" }, + reason: "", + duration: "1h", + until: "", + timeZone: "UTC", + isSaving: false, + openCount: 0, +}) + +export const Message = defineMessageUnion({ + GotDialogMessage: { message: Dialog.Message }, + ChangedReason: { value: Schema.String }, + ChangedDuration: { duration: SilenceDuration }, + ChangedUntil: { value: Schema.String }, + ChangedScopeField: { field: ScopeField, value: Schema.String }, + ClickedCancel: {}, + ClickedSave: {}, + CompletedSave: { openCount: Schema.Number, result: Schema.Result(V2AlertSilence, LoadFailure) }, +}) +export type Message = typeof Message.Type + +export const OutMessage = defineMessageUnion({ + CreatedSilence: { silence: V2AlertSilence }, + RequestedToast: { + type: Schema.Literals(["success", "error"]), + title: Schema.String, + description: Schema.String, + }, +}) +export type OutMessage = typeof OutMessage.Type + +type Return = Update.ReturnWithOutMessage + +const HOUR_MS = 60 * 60_000 +const LOCAL_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/ + +/** A `datetime-local` value read as a wall clock in `timeZone`. */ +export const parseLocalDateTime = (value: string, timeZone: string): Option.Option => { + const match = LOCAL_DATE_TIME.exec(value) + if (match === null) return Option.none() + const [year, month, day, hour, minute] = Arr.map(match.slice(1), Number) + if ( + year === undefined || + month === undefined || + day === undefined || + hour === undefined || + minute === undefined + ) + return Option.none() + return Option.some(zonedPartsToEpochMs({ year, month, day, hour, minute, second: 0 }, timeZone)) +} + +/** When a silence picked at `nowMs` ends; none for a custom end that is missing or not in the future. */ +export const silenceEndMs = ( + duration: SilenceDuration, + until: string, + nowMs: number, + timeZone: string, +): Option.Option => { + switch (duration) { + case "1h": + return Option.some(nowMs + HOUR_MS) + case "4h": + return Option.some(nowMs + 4 * HOUR_MS) + case "tomorrow": { + const today = zonedDateParts(nowMs, timeZone) + return Option.some( + zonedPartsToEpochMs( + { ...today, day: today.day + 1, hour: 9, minute: 0, second: 0 }, + timeZone, + ), + ) + } + case "custom": + return Option.filter(parseLocalDateTime(until, timeZone), (endMs) => endMs > nowMs) + } +} + +const splitList = (value: string): ReadonlyArray => + Arr.dedupe(Arr.filter(Arr.map(Str.split(value, ","), Str.trim), Str.isNonEmpty)) + +/** The matchers the form describes; empty matchers mean there is nothing to save yet. */ +export const matchersOf = (scope: Scope): V2AlertSilenceMatchers => { + if (scope._tag === "Fixed") return scope.matchers + const services = splitList(scope.services) + const tags = splitList(scope.tags) + const environments = splitList(scope.environments) + return { + ...(services.length === 0 ? undefined : { services }), + ...(tags.length === 0 ? undefined : { tags }), + ...(environments.length === 0 ? undefined : { environments }), + } +} + +const hasMatchers = (matchers: V2AlertSilenceMatchers) => + (matchers.rule_ids?.length ?? 0) + + (matchers.services?.length ?? 0) + + (matchers.tags?.length ?? 0) + + (matchers.environments?.length ?? 0) > + 0 + +/** Ready to save: a reason, something to match and, for a custom end, a parseable time. */ +export const isFormReady = (model: Model): boolean => + Str.isNonEmpty(Str.trim(model.reason)) && + hasMatchers(matchersOf(model.scope)) && + (model.duration !== "custom" || Option.isSome(parseLocalDateTime(model.until, model.timeZone))) + +// COMMANDS + +export const SaveSilence = Command.define("SaveAlertSilence", { + args: { + openCount: Schema.Number, + reason: Schema.String, + matchers: V2AlertSilenceMatchers, + duration: SilenceDuration, + until: Schema.String, + timeZone: Schema.String, + }, + messages: [Message.CompletedSave], + execute: ({ openCount, reason, matchers, duration, until, timeZone }) => + Effect.gen(function* () { + const nowMs = yield* Clock.currentTimeMillis + const endsAt = Option.getOrElse(silenceEndMs(duration, until, nowMs, timeZone), () => nowMs) + return yield* createSilence({ + reason, + matchers, + ends_at: DateTime.formatIso(DateTime.makeUnsafe(endsAt)), + }) + }).pipe( + Effect.result, + Effect.map((result) => Message.CompletedSave({ openCount, result })), + ), +}) + +// UPDATE + +const liftDialog = (model: Model, result: ReturnType): Return => ({ + model: { ...model, dialog: result.model }, + commands: Command.mapMessages(result.commands ?? [], (message) => Message.GotDialogMessage({ message })), +}) + +const open = (model: Model, scope: Scope, timeZone: string): Return => + liftDialog( + { + ...model, + scope, + timeZone, + reason: "", + duration: "1h", + until: "", + isSaving: false, + openCount: model.openCount + 1, + }, + Dialog.open(model.dialog), + ) + +/** Silence one rule, or one group of it: the scope is fixed and named in the dialog. */ +export const openFor = ( + model: Model, + target: { readonly label: string; readonly matchers: V2AlertSilenceMatchers }, + timeZone: string, +): Return => open(model, { _tag: "Fixed", ...target }, timeZone) + +/** A silence the user scopes by service, tag and environment. */ +export const openNew = (model: Model, timeZone: string): Return => + open(model, { _tag: "Typed", services: "", tags: "", environments: "" }, timeZone) + +const save = (model: Model): Return => + model.isSaving || !isFormReady(model) + ? { model } + : { + model: { ...model, isSaving: true }, + commands: [ + SaveSilence({ + openCount: model.openCount, + reason: Str.trim(model.reason), + matchers: matchersOf(model.scope), + duration: model.duration, + until: model.until, + timeZone: model.timeZone, + }), + ], + } + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotDialogMessage: ({ message: child }) => liftDialog(model, Dialog.update(model.dialog, child)), + ChangedReason: ({ value }) => ({ model: { ...model, reason: value } }), + ChangedDuration: ({ duration }) => ({ model: { ...model, duration } }), + ChangedUntil: ({ value }) => ({ model: { ...model, until: value } }), + ChangedScopeField: ({ field, value }) => + model.scope._tag === "Fixed" + ? { model } + : { model: { ...model, scope: { ...model.scope, [field]: value } } }, + ClickedCancel: () => liftDialog(model, Dialog.close(model.dialog)), + ClickedSave: () => save(model), + CompletedSave: ({ openCount, result }) => + Result.match(result, { + onSuccess: (silence) => { + const settled = { ...model, isSaving: false } + const closed = + openCount === model.openCount + ? liftDialog(settled, Dialog.close(model.dialog)) + : { model: settled } + return { ...closed, outMessage: OutMessage.CreatedSilence({ silence }) } + }, + onFailure: (failure) => ({ + model: { ...model, isSaving: false }, + outMessage: OutMessage.RequestedToast({ + type: "error", + title: "Couldn't silence alerts", + description: failure.message, + }), + }), + }), + }) diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts new file mode 100644 index 0000000000..1be937c2d0 --- /dev/null +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -0,0 +1,170 @@ +import { Array as Arr } from "effect" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { eyebrow } from "@maple/views/ui/eyebrow" +import { fieldDescription, fieldLabel, type FieldIds, input } from "@maple/views/ui/field" +import { formDialog } from "@maple/views/ui/form-dialog" +import type { FieldState } from "@maple/views/ui/input" +import { panel } from "@maple/views/ui/panel" +import { segmentedSelect } from "@maple/views/ui/segmented-select" +import { vstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" +import type { V2AlertSilenceMatchers } from "@maple/domain/http/v2" +import { isFormReady, Message, type Model, type ScopeField, type SilenceDuration } from "./dialog" + +type H = HtmlBuilder + +const DURATIONS: ReadonlyArray<{ value: SilenceDuration; label: string }> = [ + { value: "1h", label: "1 hour" }, + { value: "4h", label: "4 hours" }, + { value: "tomorrow", label: "Until 09:00 tomorrow" }, + { value: "custom", label: "Until…" }, +] + +const SUBMIT_LABELS = { + "1h": "Silence for 1 hour", + "4h": "Silence for 4 hours", + tomorrow: "Silence until tomorrow", + custom: "Silence until then", +} satisfies Record + +const stateOf = (value: string): FieldState => ({ + isInvalid: false, + isDirty: false, + isFilled: value !== "", + isFocused: false, + isTouched: false, +}) + +const ids = (name: string, hasDescription = false): FieldIds => ({ + control: `silence-${name}`, + label: `silence-${name}-label`, + error: `silence-${name}-error`, + ...(hasDescription ? { description: `silence-${name}-description` } : undefined), +}) + +const textField = ( + h: H, + options: Readonly<{ + name: string + label: string + value: string + placeholder: string + onInput: (value: string) => Message + type?: string + description?: string + }>, +): Html => { + const fieldIds = ids(options.name, options.description !== undefined) + return vstack(h, { gap: "1.5" }, [ + fieldLabel(h, { ids: fieldIds, className: "text-xs", state: stateOf(options.value) }, [ + options.label, + ]), + input(h, { + ids: fieldIds, + state: stateOf(options.value), + attributes: [ + h.Value(options.value), + h.Placeholder(options.placeholder), + h.OnInput(options.onInput), + h.Attribute("autocomplete", "off"), + ...(options.type === undefined ? [] : [h.Type(options.type)]), + ], + }), + ...(options.description === undefined + ? [] + : [fieldDescription(h, { ids: fieldIds, state: stateOf(options.value) }, [options.description])]), + ]) +} + +const SCOPE_FIELDS: ReadonlyArray<{ field: ScopeField; label: string; placeholder: string }> = [ + { field: "services", label: "Services", placeholder: "checkout, payments" }, + { field: "environments", label: "Environments", placeholder: "production" }, + { field: "tags", label: "Tags", placeholder: "database" }, +] + +/** What a scope fixed by a rule or an incident covers, in a sentence. */ +const fixedScopeLine = (matchers: V2AlertSilenceMatchers): string => { + const services = matchers.services ?? [] + return services.length > 0 + ? `Only the ${Arr.join(services, ", ")} group of this rule` + : "Every notification of this rule" +} + +const scopeSection = (h: H, model: Model): Html => + vstack(h, { gap: "2" }, [ + eyebrow(h, { variant: "label", as: "div" }, ["Covers"]), + model.scope._tag === "Fixed" + ? panel(h, { padded: true, className: "gap-1" }, [ + typography(h, { size: "sm", weight: "medium" }, [model.scope.label]), + typography(h, { size: "xs", tone: "muted" }, [fixedScopeLine(model.scope.matchers)]), + ]) + : panel(h, { padded: true, className: "gap-3" }, [ + ...Arr.map(SCOPE_FIELDS, ({ field, label, placeholder }) => + textField(h, { + name: field, + label, + value: model.scope._tag === "Typed" ? model.scope[field] : "", + placeholder, + onInput: (value) => Message.ChangedScopeField({ field, value }), + }), + ), + typography(h, { size: "xs", tone: "muted" }, [ + "Fill in at least one. A rule is silenced when all of its services and environments are listed and it carries one of the tags.", + ]), + ]), + ]) + +const durationSection = (h: H, model: Model): Html => + vstack(h, { gap: "2" }, [ + eyebrow(h, { variant: "label", as: "div" }, ["For"]), + segmentedSelect(h, { + ariaLabel: "Silence duration", + options: DURATIONS, + value: model.duration, + onChange: (duration) => Message.ChangedDuration({ duration }), + size: "sm", + }), + ...(model.duration === "custom" + ? [ + textField(h, { + name: "until", + label: "Ends", + value: model.until, + placeholder: "", + type: "datetime-local", + onInput: (value) => Message.ChangedUntil({ value }), + description: `In ${model.timeZone}. At most 30 days from now.`, + }), + ] + : []), + ]) + +/** The "Silence notifications" dialog: what it covers, for how long, and why. */ +export const view = Submodel.defineView((model, inputs, h) => + formDialog(h, { + slotId: `${inputs.id}-dialog`, + model: model.dialog, + toParentMessage: (message) => Message.GotDialogMessage({ message }), + className: "sm:max-w-lg", + title: "Silence notifications", + description: + "Rules keep checking and incidents still open and resolve, but no one is paged. If an incident is still open when the silence ends, its page goes out then.", + panelClassName: "space-y-5", + fields: [ + scopeSection(h, model), + durationSection(h, model), + textField(h, { + name: "reason", + label: "Reason", + value: model.reason, + placeholder: "Deploying checkout v2.3", + onInput: (value) => Message.ChangedReason({ value }), + }), + ], + onSubmit: Message.ClickedSave(), + submitLabel: SUBMIT_LABELS[model.duration], + isPending: model.isSaving, + isSubmitDisabled: !isFormReady(model), + }), +) diff --git a/packages/views/src/ui/icons.generated.ts b/packages/views/src/ui/icons.generated.ts index 5931926853..13ebce1210 100644 --- a/packages/views/src/ui/icons.generated.ts +++ b/packages/views/src/ui/icons.generated.ts @@ -1832,6 +1832,230 @@ export const ICON_TREES = { ], ], ], + BellSlashIcon: [ + "svg", + [ + ["xmlns", "http://www.w3.org/2000/svg"], + ["viewBox", "0 0 24 24"], + ["width", "7777"], + ["height", "7777"], + ["class", "__icon_class__"], + ["fill", "none"], + ["aria-hidden", "true"], + ], + [ + [ + "path", + [ + ["d", "M9 20H9.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M15 20H15.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M16 4H16.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M8 4H8.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M11 22H13"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M10 2H14"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M21 16L21 14"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M3 16L3 14"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M18 6L18 12L19 12"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M6 9L6 12L5 12"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M5 18H14"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M3 3H3.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M5 5H5.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M7 7H7.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M9 9H9.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M11 11H11.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M13 13H13.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M15 15H15.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M17 17H17.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M19 19H19.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M21 21H21.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + ], + ], BellIcon: [ "svg", [ diff --git a/packages/web-data/src/alerts/incident-timeline.test.ts b/packages/web-data/src/alerts/incident-timeline.test.ts index d2e1fcd499..36833b4a40 100644 --- a/packages/web-data/src/alerts/incident-timeline.test.ts +++ b/packages/web-data/src/alerts/incident-timeline.test.ts @@ -99,6 +99,13 @@ describe("buildIncidentTimeline", () => { expect(failed?.detail).toBe("discord · webhook returned 500") }) + it("names a page a silence held back", () => { + const [, silenced] = build(makeIncident(), [makeDelivery({ status: "silenced" })]) + expect(silenced?.title).toBe("Page to #alerts held back by a silence") + expect(silenced?.tone).toBe("neutral") + expect(silenced?.pending).toBe(false) + }) + it("ends a held incident on the hold instead of a breach", () => { const entries = build(makeIncident({ holdReason: "no_data", heldSince: "2026-10-15T14:40:00.000Z" })) expect(entries.at(-1)?.title).toBe("Waiting on data") diff --git a/packages/web-data/src/alerts/incident-timeline.ts b/packages/web-data/src/alerts/incident-timeline.ts index 754627e41f..5ce2f5bbc1 100644 --- a/packages/web-data/src/alerts/incident-timeline.ts +++ b/packages/web-data/src/alerts/incident-timeline.ts @@ -76,19 +76,24 @@ function deliveryEntry( const name = event.destinationName const failed = event.status === "failed" const sending = event.status === "queued" || event.status === "processing" - const title = sending - ? `Sending to ${name}` - : event.eventType === "resolve" - ? failed - ? `All-clear to ${name} failed` - : `Sent all-clear to ${name}` - : event.eventType === "renotify" + const silenced = event.status === "silenced" + const title = silenced + ? event.eventType === "resolve" + ? `All-clear to ${name} held back by a silence` + : `Page to ${name} held back by a silence` + : sending + ? `Sending to ${name}` + : event.eventType === "resolve" ? failed - ? `Reminder to ${name} failed` - : `Reminded ${name}` - : failed - ? `Could not notify ${name}` - : `Notified ${name}` + ? `All-clear to ${name} failed` + : `Sent all-clear to ${name}` + : event.eventType === "renotify" + ? failed + ? `Reminder to ${name} failed` + : `Reminded ${name}` + : failed + ? `Could not notify ${name}` + : `Notified ${name}` const reason = failed ? (event.errorMessage ?? (event.responseCode != null ? `HTTP ${event.responseCode}` : null)) : null @@ -100,7 +105,13 @@ function deliveryEntry( return { key: `delivery:${event.id}`, at: Date.parse(event.attemptedAt ?? event.scheduledAt), - tone: failed ? "crit" : sending ? "neutral" : event.eventType === "resolve" ? "ok" : "info", + tone: failed + ? "crit" + : sending || silenced + ? "neutral" + : event.eventType === "resolve" + ? "ok" + : "info", pending: sending, title, detail: Arr.join( From 2351fb07fe656b6b7b2c3dfe5843f28ad8b7c6ae Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 01:55:03 +0100 Subject: [PATCH 04/28] fix(web): silence review fixes - Silence shows only for roles that can manage alerts, on rules and open incidents, and waits for the rule so an incident's group is scoped right. - A custom end must be ahead and within 30 days, with a field error; the submit label names the end. "Until 09:00" is the next 09:00. - The badge shows when notifications resume (the latest-ending silence), names the group when only one is silenced, and says when silences could not be read. - Rule and incident pages read only active silences; the hub reads active, scheduled and ended separately so a full page cannot hide the ones in force. - Group scoping mirrors the server for multi-service and environment-grouped rules. --- apps/web/src/page/alerts/hub/update.ts | 6 +- apps/web/src/page/alerts/hub/view-silences.ts | 17 +---- apps/web/src/page/alerts/hub/view.ts | 26 +++----- apps/web/src/page/alerts/incident/command.ts | 10 ++- apps/web/src/page/alerts/incident/message.ts | 2 + apps/web/src/page/alerts/incident/model.ts | 3 + apps/web/src/page/alerts/incident/update.ts | 56 ++++++++++------ apps/web/src/page/alerts/incident/view.ts | 48 +++++++++----- apps/web/src/page/alerts/rule/command.ts | 10 ++- apps/web/src/page/alerts/rule/message.ts | 2 + apps/web/src/page/alerts/rule/model.ts | 3 + .../src/page/alerts/rule/rule.scene.test.ts | 57 ++++++++++++++++- apps/web/src/page/alerts/rule/update.ts | 7 +- apps/web/src/page/alerts/rule/view.ts | 50 +++++++++++---- .../src/page/alerts/shared/silence-view.ts | 13 +++- .../src/page/alerts/shared/silences.test.ts | 34 +++++++++- apps/web/src/page/alerts/shared/silences.ts | 62 ++++++++++++------ .../page/alerts/silence-dialog/dialog.test.ts | 24 ++++++- .../src/page/alerts/silence-dialog/dialog.ts | 64 +++++++++++++------ .../src/page/alerts/silence-dialog/view.ts | 39 ++++++++--- 20 files changed, 386 insertions(+), 147 deletions(-) diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index cee1586749..74b55ea715 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -610,7 +610,11 @@ export const update = (model: Model, message: Message, context: PageContext): Re }), ClickedRetrySilences: () => refreshSilences(model, context), ClickedNewSilence: () => - liftSilenceDialog(model, context, SilenceDialog.openNew(model.silenceDialog, context.timeZone)), + liftSilenceDialog( + model, + context, + SilenceDialog.openNew(model.silenceDialog, context.timeZone, model.nowMs ?? 0), + ), GotSilenceDialogMessage: ({ message: child }) => liftSilenceDialog(model, context, SilenceDialog.update(model.silenceDialog, child)), ClickedEndSilence: ({ silenceId }) => endSilence(model, silenceId), diff --git a/apps/web/src/page/alerts/hub/view-silences.ts b/apps/web/src/page/alerts/hub/view-silences.ts index a847bc7576..6b40b502b8 100644 --- a/apps/web/src/page/alerts/hub/view-silences.ts +++ b/apps/web/src/page/alerts/hub/view-silences.ts @@ -5,7 +5,6 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { badge } from "@maple/views/ui/display" import { button } from "@maple/views/ui/button" import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "@maple/views/ui/empty" -import { emptyActions } from "@maple/views/ui/signal-empty-state" import { errorState } from "@maple/views/ui/error-state" import { icon } from "@maple/views/ui/icon" import { listRow } from "@maple/views/ui/list-row" @@ -31,7 +30,7 @@ type H = HtmlBuilder /** Ended silences stay listed for a week, so a recent maintenance window is still visible. */ const ENDED_VISIBLE_MS = 7 * 24 * 60 * 60_000 -const silencesEmptyState = (h: H, canEdit: boolean): Html => +const silencesEmptyState = (h: H): Html => empty( h, [ @@ -42,16 +41,6 @@ const silencesEmptyState = (h: H, canEdit: boolean): Html => "Silence a rule, a service or an environment during a deploy or a planned migration. Rules keep checking and incidents still open, but no one is paged.", ]), ]), - ...(canEdit - ? [ - emptyActions(h, [ - button(h, { size: "sm", attributes: [h.OnClick(Message.ClickedNewSilence())] }, [ - icon(h, "PlusIcon"), - "New silence", - ]), - ]), - ] - : []), ], "py-12", ) @@ -140,14 +129,14 @@ const loaded = ( Arr.findFirst(rules, (rule) => rule.id === id), (rule) => rule.name, ), - () => "deleted rule", + () => (model.rules === null ? "rule" : "deleted rule"), ) const byStatus = (status: V2AlertSilence["status"]) => Arr.filter(silences, (silence) => silenceStatusAt(silence, nowMs) === status) const ended = Arr.filter(byStatus("ended"), (silence) => endsAtMs(silence) > nowMs - ENDED_VISIBLE_MS) const active = byStatus("active") const scheduled = byStatus("scheduled") - if (active.length + scheduled.length + ended.length === 0) return silencesEmptyState(h, canManage(model)) + if (active.length + scheduled.length + ended.length === 0) return silencesEmptyState(h) const options = { nowMs, timeZone, ruleName } return vstack(h, { gap: "6" }, [ ...section(h, model, "Active", active, options), diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index 23db155d81..6a7d45013f 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -22,7 +22,6 @@ import { inboxTab } from "./view-inbox" import { routingTab } from "./view-routing" import * as DestinationDialogView from "../destination/view" import * as SilenceDialogView from "../silence-dialog/view" -import { silenceStatusAt } from "../shared/silences" import { silencesTab } from "./view-silences" import { rulesTab } from "./view-rules" import { deleteMonitorDialog, uptimeTab } from "./view-uptime" @@ -49,11 +48,6 @@ const tabBar = ( active: AlertsTab, ): Html => { const monitorCount = Option.map(AsyncData.getData(model.monitors.data), (monitors) => monitors.length) - const activeSilenceCount = Option.map( - AsyncData.getData(model.silences.data), - (silences) => - Arr.filter(silences, (silence) => silenceStatusAt(silence, model.nowMs ?? 0) === "active").length, - ) return linkTabStrip( h, "Alerts sections", @@ -61,17 +55,15 @@ const tabBar = ( const count = tab.value === "uptime" ? Option.getOrUndefined(monitorCount) - : tab.value === "silences" - ? Option.getOrUndefined(activeSilenceCount) - : Option.match(overview, { - onNone: () => undefined, - onSome: (data) => - tab.value === "inbox" - ? data.openIncidents.length - : tab.value === "rules" - ? data.rules.length - : undefined, - }) + : Option.match(overview, { + onNone: () => undefined, + onSome: (data) => + tab.value === "inbox" + ? data.openIncidents.length + : tab.value === "rules" + ? data.rules.length + : undefined, + }) return { key: tab.value, href: alertsHref({ ...model.search, tab: tab.value }), diff --git a/apps/web/src/page/alerts/incident/command.ts b/apps/web/src/page/alerts/incident/command.ts index ac695c0a13..3bd7ebbb2c 100644 --- a/apps/web/src/page/alerts/incident/command.ts +++ b/apps/web/src/page/alerts/incident/command.ts @@ -5,7 +5,8 @@ import { Command } from "foldkit" import { toLoadFailure } from "@maple/views/data/load-failure" import { MapleApiV2Client } from "../../../platform/api-clients" import { listDeliveries } from "../shared/commands" -import { listSilences } from "../shared/silences" +import { listActiveSilences } from "../shared/silences" +import { readGrants } from "../../../data/session-tenant" import { fetchCheckSummary } from "../shared/check-summary" import { fetchPreview } from "../shared/preview" import { Message } from "./message" @@ -83,8 +84,13 @@ export const FetchIncidentSilences = Command.define("FetchAlertIncidentSilences" args: { key: Schema.String }, messages: [Message.CompletedFetchSilences], execute: ({ key }) => - listSilences.pipe( + listActiveSilences.pipe( Effect.result, Effect.map((result) => Message.CompletedFetchSilences({ key, result })), ), }) + +export const FetchIncidentSession = Command.define("FetchAlertIncidentSession", { + messages: [Message.CompletedFetchSession], + execute: Effect.map(readGrants, (grants) => Message.CompletedFetchSession({ grants })), +}) diff --git a/apps/web/src/page/alerts/incident/message.ts b/apps/web/src/page/alerts/incident/message.ts index 780af2aae6..c8221ce058 100644 --- a/apps/web/src/page/alerts/incident/message.ts +++ b/apps/web/src/page/alerts/incident/message.ts @@ -6,6 +6,7 @@ import { AlertRuleStateRowSchema, } from "@maple/web-data/collections/alerts" import { Schema } from "effect" +import * as Policy from "@maple/domain/policy" import { defineMessageUnion } from "foldkit/message" import * as Chart from "@maple/views/charts/chart" import { ThemeColors } from "@maple/views/charts/theme-colors" @@ -46,5 +47,6 @@ export const Message = defineMessageUnion({ }, ClickedSilence: {}, GotSilenceDialogMessage: { message: SilenceDialog.Message }, + CompletedFetchSession: { grants: Schema.Array(Policy.PermissionName) }, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/incident/model.ts b/apps/web/src/page/alerts/incident/model.ts index 72011a2974..5bd183fe1f 100644 --- a/apps/web/src/page/alerts/incident/model.ts +++ b/apps/web/src/page/alerts/incident/model.ts @@ -6,6 +6,7 @@ import { AlertRuleStateRowSchema, } from "@maple/web-data/collections/alerts" import { Schema } from "effect" +import * as Policy from "@maple/domain/policy" import * as Chart from "@maple/views/charts/chart" import { ChartRuntime } from "@maple/views/charts/chart-runtime" import { ThemeColors } from "@maple/views/charts/theme-colors" @@ -39,5 +40,7 @@ export const Model = Schema.Struct({ /** `alertSilences.list`: which silence, if any, holds back this incident's notifications. */ silences: KeyedSchema(Schema.Array(V2AlertSilence), LoadFailure), silenceDialog: SilenceDialog.Model, + /** What the session may do (`Policy.grantsOf`); none until it answers, so Silence stays hidden. */ + grants: Schema.Array(Policy.PermissionName), }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/alerts/incident/update.ts b/apps/web/src/page/alerts/incident/update.ts index 0ce12dbbd3..e1c33412a3 100644 --- a/apps/web/src/page/alerts/incident/update.ts +++ b/apps/web/src/page/alerts/incident/update.ts @@ -21,6 +21,7 @@ import { FetchIncidentDeliveries, FetchIncidentInvestigation, FetchIncidentPreview, + FetchIncidentSession, FetchIncidentSilences, ReadIncidentClock, } from "./command" @@ -35,7 +36,7 @@ type Commands = ReadonlyArray> type IncidentRoute = Extract const silencesKey = (context: PageContext) => - requestKey(context.orgId, "alertSilences.list", { query: { limit: 100 } }) + requestKey(context.orgId, "alertSilences.list", { query: { status: "active", limit: 100 } }) const requestSilences = (model: Model, context: PageContext, mode: Keyed.ReadMode): Return => Option.match(Keyed.requestFor(model.silences, silencesKey(context), mode), { @@ -80,25 +81,33 @@ const liftSilenceDialog = ( /** Silence this incident's rule, or only its group when the rule is grouped by service. */ const openSilence = (model: Model, context: PageContext): Return => - Option.match(incidentViewOf(model), { - onNone: () => ({ model }), - onSome: ({ incident, rule }) => { - const group = incidentGroup(incident) - const name = rule?.name ?? incident.ruleName - const target = - rule === null - ? { label: name, matchers: { rule_ids: [incident.ruleId] } } - : { - label: group === null ? name : `${name} · ${group}`, - matchers: matchersForRule(rule, group), - } - return liftSilenceDialog( - model, - context, - SilenceDialog.openFor(model.silenceDialog, target, context.timeZone), - ) - }, - }) + // The rule decides whether one group or the whole rule is silenced, so wait for it. + model.rules === null + ? { model } + : Option.match(incidentViewOf(model), { + onNone: () => ({ model }), + onSome: ({ incident, rule }) => { + const group = incidentGroup(incident) + const name = rule?.name ?? incident.ruleName + const target = + rule === null + ? { label: name, matchers: { rule_ids: [incident.ruleId] } } + : { + label: group === null ? name : `${name} · ${group}`, + matchers: matchersForRule(rule, group), + } + return liftSilenceDialog( + model, + context, + SilenceDialog.openFor( + model.silenceDialog, + target, + context.timeZone, + model.nowMs ?? 0, + ), + ) + }, + }) export const init = (route: IncidentRoute, context: PageContext): Return => { const initial: Return = { @@ -118,6 +127,7 @@ export const init = (route: IncidentRoute, context: PageContext): Return => { themeColors: null, silences: Keyed.idle(), silenceDialog: SilenceDialog.init("alert-incident-silence"), + grants: [], }, commands: [ ReadIncidentClock(), @@ -129,7 +139,10 @@ export const init = (route: IncidentRoute, context: PageContext): Return => { ], } const silences = requestSilences(initial.model, context, "Load") - return { model: silences.model, commands: [...(initial.commands ?? []), ...(silences.commands ?? [])] } + return { + model: silences.model, + commands: [...(initial.commands ?? []), ...(silences.commands ?? []), FetchIncidentSession()], + } } /** An atom never refetches a failed read on its own: a same-key `Load` leaves the slot alone. */ @@ -357,6 +370,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re model: { ...model, silences: Keyed.accept(model.silences, key, result) }, }), ClickedSilence: () => openSilence(model, context), + CompletedFetchSession: ({ grants }) => ({ model: { ...model, grants } }), GotSilenceDialogMessage: ({ message: child }) => liftSilenceDialog(model, context, SilenceDialog.update(model.silenceDialog, child)), GotChartMessage: ({ message: child }) => { diff --git a/apps/web/src/page/alerts/incident/view.ts b/apps/web/src/page/alerts/incident/view.ts index 5a5bd0b3ce..360756b97d 100644 --- a/apps/web/src/page/alerts/incident/view.ts +++ b/apps/web/src/page/alerts/incident/view.ts @@ -49,7 +49,8 @@ import { Message } from "./message" import type { Model } from "./model" import { incidentRail } from "./view-rail" import { activeSilenceFor } from "../shared/silences" -import { silenceBadge, silenceButton } from "../shared/silence-view" +import { silenceBadge, silenceButton, silenceCheckFailed } from "../shared/silence-view" +import * as Policy from "@maple/domain/policy" import * as SilenceDialogView from "../silence-dialog/view" import { hstack } from "@maple/views/ui/stack" import { incidentTimeline } from "./view-timeline" @@ -119,10 +120,37 @@ export const headerTitle = ( const headerAction = (h: H, model: Model, investigation: V2Investigation | null): Html => hstack(h, { gap: "2" }, [ - silenceButton(h, Message.ClickedSilence()), + ...(Policy.has(model.grants, "alerts:manage") && isOpenIncident(model) + ? [silenceButton(h, Message.ClickedSilence(), model.rules === null)] + : []), investigateAction(h, model, investigation), ]) +const isOpenIncident = (model: Model): boolean => + Option.match(incidentViewOf(model), { + onNone: () => false, + onSome: ({ incident }) => incident.status === "open", + }) + +/** "Silenced until 15:00" over an open incident, or a note when the silences could not be read. */ +const silenceBadges = (h: H, model: Model, view: IncidentView, now: number, timeZone: string) => { + if (view.incident.status !== "open") return [] + if (AsyncData.isFailure(model.silences.data)) return [silenceCheckFailed(h)] + return Option.toArray( + Option.map( + Option.flatMap(Option.fromNullishOr(view.rule), (rule) => + activeSilenceFor( + AsyncData.getOrElse(model.silences.data, () => []), + rule, + view.incident.groupKey, + now, + ), + ), + (silence) => silenceBadge(h, silence, now, timeZone), + ), + ) +} + const investigateAction = (h: H, model: Model, investigation: V2Investigation | null): Html => investigation !== null ? linkButton( @@ -321,21 +349,7 @@ export const view = Submodel.defineView((model, view.rule, now, timeZone, - view.incident.status === "open" - ? Option.toArray( - Option.map( - Option.flatMap(Option.fromNullishOr(view.rule), (rule) => - activeSilenceFor( - AsyncData.getOrElse(model.silences.data, () => []), - rule, - view.incident.groupKey, - now, - ), - ), - (silence) => silenceBadge(h, silence, now, timeZone), - ), - ) - : [], + silenceBadges(h, model, view, now, timeZone), ), actions: headerAction( h, diff --git a/apps/web/src/page/alerts/rule/command.ts b/apps/web/src/page/alerts/rule/command.ts index ecaec64e8f..a4b2518ba3 100644 --- a/apps/web/src/page/alerts/rule/command.ts +++ b/apps/web/src/page/alerts/rule/command.ts @@ -7,7 +7,8 @@ import { toLoadFailure } from "@maple/views/data/load-failure" import { resolveWindow } from "../../../data/time-range" import { MapleApiV2Client } from "../../../platform/api-clients" import { listDeliveries, setRuleEnabled } from "../shared/commands" -import { listSilences } from "../shared/silences" +import { listActiveSilences } from "../shared/silences" +import { readGrants } from "../../../data/session-tenant" import { fetchCheckSummary } from "../shared/check-summary" import { fetchPreview } from "../shared/preview" import { CheckStatusFilter } from "./model" @@ -172,8 +173,13 @@ export const FetchRuleSilences = Command.define("FetchAlertRuleSilences", { args: { key: Schema.String }, messages: [Message.CompletedFetchSilences], execute: ({ key }) => - listSilences.pipe( + listActiveSilences.pipe( Effect.result, Effect.map((result) => Message.CompletedFetchSilences({ key, result })), ), }) + +export const FetchRuleSession = Command.define("FetchAlertRuleSession", { + messages: [Message.CompletedFetchSession], + execute: Effect.map(readGrants, (grants) => Message.CompletedFetchSession({ grants })), +}) diff --git a/apps/web/src/page/alerts/rule/message.ts b/apps/web/src/page/alerts/rule/message.ts index 33b1354789..ce228257ba 100644 --- a/apps/web/src/page/alerts/rule/message.ts +++ b/apps/web/src/page/alerts/rule/message.ts @@ -11,6 +11,7 @@ import { AlertRuleStateRowSchema, } from "@maple/web-data/collections/alerts" import { Schema } from "effect" +import * as Policy from "@maple/domain/policy" import { defineMessageUnion } from "foldkit/message" import * as Chart from "@maple/views/charts/chart" import { ThemeColors } from "@maple/views/charts/theme-colors" @@ -90,5 +91,6 @@ export const Message = defineMessageUnion({ }, ClickedSilence: {}, GotSilenceDialogMessage: { message: SilenceDialog.Message }, + CompletedFetchSession: { grants: Schema.Array(Policy.PermissionName) }, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/rule/model.ts b/apps/web/src/page/alerts/rule/model.ts index a570b3023b..5c571dc3f1 100644 --- a/apps/web/src/page/alerts/rule/model.ts +++ b/apps/web/src/page/alerts/rule/model.ts @@ -6,6 +6,7 @@ import { AlertRuleStateRowSchema, } from "@maple/web-data/collections/alerts" import { Schema } from "effect" +import * as Policy from "@maple/domain/policy" import * as Chart from "@maple/views/charts/chart" import { ChartRuntime } from "@maple/views/charts/chart-runtime" import { ThemeColors } from "@maple/views/charts/theme-colors" @@ -93,5 +94,7 @@ export const Model = Schema.Struct({ /** `alertSilences.list`: which silence, if any, holds back this rule's notifications. */ silences: KeyedSchema(Schema.Array(V2AlertSilence), LoadFailure), silenceDialog: SilenceDialog.Model, + /** What the session may do (`Policy.grantsOf`); none until it answers, so Silence stays hidden. */ + grants: Schema.Array(Policy.PermissionName), }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/alerts/rule/rule.scene.test.ts b/apps/web/src/page/alerts/rule/rule.scene.test.ts index 21827a40ea..43a8422656 100644 --- a/apps/web/src/page/alerts/rule/rule.scene.test.ts +++ b/apps/web/src/page/alerts/rule/rule.scene.test.ts @@ -1,5 +1,6 @@ import { OrgId } from "@maple/domain" -import { AlertCheckDocument, AlertRuleId } from "@maple/domain/http" +import { AlertCheckDocument, AlertRuleId, AlertSilenceId } from "@maple/domain/http" +import * as Policy from "@maple/domain/policy" import { Result, Schema, Array as Arr } from "effect" import { all, @@ -351,3 +352,57 @@ describe("alert rule", () => { ) }) }) + +describe("alert rule page, silences", () => { + const silence = { + id: Schema.decodeSync(AlertSilenceId)("22222222-2222-4222-8222-222222222222"), + object: "alert_silence" as const, + reason: "Deploying payments", + matchers: { rule_ids: [RULE] }, + status: "active" as const, + starts_at: new Date(NOW - 60_000).toISOString(), + ends_at: new Date(NOW + 60 * 60_000).toISOString(), + created_by: "user_1", + created_at: new Date(NOW).toISOString(), + updated_at: new Date(NOW).toISOString(), + } + const withGrants = (model: Model, roles: ReadonlyArray) => + pageUpdate(model, Message.CompletedFetchSession({ grants: Policy.grantsOf(roles) })).model + const withSilences = ( + model: Model, + result: Parameters[0]["result"], + ) => pageUpdate(model, Message.CompletedFetchSilences({ key: model.silences.key ?? "", result })).model + + test("offers Silence only to a role that can manage alerts", () => { + expect(names(init(route(), context).commands)).toEqual( + expect.arrayContaining(["FetchAlertRuleSilences", "FetchAlertRuleSession"]), + ) + scene( + { update: pageUpdate, view }, + given(withGrants(loaded(), ["org:member"])), + themeResolved(), + expectView(role("button", { name: "Silence" })).not.toExist(), + ) + scene( + { update: pageUpdate, view }, + given(withGrants(loaded(), ["org:engineer"])), + themeResolved(), + expectView(role("button", { name: "Silence" })).toExist(), + ) + }) + + test("shows the silence in force, and says so when silences could not be read", () => { + scene( + { update: pageUpdate, view }, + given(withSilences(loaded(), Result.succeed([silence]))), + themeResolved(), + expectView(text(/^Silenced until /u)).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(withSilences(loaded(), Result.fail(toLoadFailure(new Error("down"))))), + themeResolved(), + expectView(text("Couldn't check silences")).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/rule/update.ts b/apps/web/src/page/alerts/rule/update.ts index 3bc1b0ed0b..06177371b6 100644 --- a/apps/web/src/page/alerts/rule/update.ts +++ b/apps/web/src/page/alerts/rule/update.ts @@ -26,6 +26,7 @@ import { ReadDiagnosisClock, ResolveRuleWindow, ToggleRuleEnabled, + FetchRuleSession, FetchRuleSilences, } from "./command" import { @@ -43,7 +44,7 @@ import * as SilenceDialog from "../silence-dialog/dialog" import { matchersForRule } from "../shared/silences" const silencesKey = (context: PageContext) => - requestKey(context.orgId, "alertSilences.list", { query: { limit: 100 } }) + requestKey(context.orgId, "alertSilences.list", { query: { status: "active", limit: 100 } }) const requestSilences = (model: Model, context: PageContext, mode: Keyed.ReadMode): Return => Option.match(Keyed.requestFor(model.silences, silencesKey(context), mode), { @@ -97,6 +98,7 @@ const openSilence = (model: Model, context: PageContext): Return => { model.silenceDialog, { label: rule.name, matchers: matchersForRule(rule, null) }, context.timeZone, + model.nowMs, ), ) } @@ -214,6 +216,7 @@ export const init = (route: RuleRoute, context: PageContext): Return => { themeColors: null, silences: Keyed.idle(), silenceDialog: SilenceDialog.init("alert-rule-silence"), + grants: [], } const silences = requestSilences(model, context, "Load") const followed = followData(silences.model, context, "Load") @@ -221,6 +224,7 @@ export const init = (route: RuleRoute, context: PageContext): Return => { model: followed.model, commands: [ ...(silences.commands ?? []), + FetchRuleSession(), resolveCommand(model, context, "Load"), ReadDiagnosisClock(), ...(followed.commands ?? []), @@ -565,6 +569,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re model: { ...model, silences: Keyed.accept(model.silences, key, result) }, }), ClickedSilence: () => openSilence(model, context), + CompletedFetchSession: ({ grants }) => ({ model: { ...model, grants } }), GotSilenceDialogMessage: ({ message: child }) => liftSilenceDialog(model, context, SilenceDialog.update(model.silenceDialog, child)), GotChartMessage: ({ message: child }) => { diff --git a/apps/web/src/page/alerts/rule/view.ts b/apps/web/src/page/alerts/rule/view.ts index b9ea9e6233..bc4851c344 100644 --- a/apps/web/src/page/alerts/rule/view.ts +++ b/apps/web/src/page/alerts/rule/view.ts @@ -26,7 +26,10 @@ import { typography } from "@maple/views/ui/typography" import { hstack } from "@maple/views/ui/stack" import { Option, Array as Arr } from "effect" import { activeSilenceFor } from "../shared/silences" -import { silenceBadge, silenceButton } from "../shared/silence-view" +import { silenceBadge, silenceButton, silenceCheckFailed } from "../shared/silence-view" +import * as Policy from "@maple/domain/policy" +import type { V2AlertSilence } from "@maple/domain/http/v2" +import { incidentGroup } from "@maple/web-data/alerts/incident-timeline" import * as SilenceDialogView from "../silence-dialog/view" // The rule detail page through `ResultPage` and `DetailHeader`. @@ -51,24 +54,37 @@ export const ruleSubtitle = (rule: AlertRuleDocument): string => { const toTimePicker = (message: TimeRangePicker.Message) => Message.GotTimePickerMessage({ message }) -/** `DetailHeader` (kind eyebrow, title, meta row) with the time controls and Edit rule. */ -/** The silence over the whole rule, or else over one of its open incidents' groups. */ +/** The silence over the whole rule, or else over one open incident's group (named in the badge). */ const ruleSilence = ( model: Model, rule: AlertRuleDocument, openIncidents: ReadonlyArray, -) => { +): Option.Option<{ readonly silence: V2AlertSilence; readonly group: string | null }> => { const silences = AsyncData.getOrElse(model.silences.data, () => []) - return Option.orElse(activeSilenceFor(silences, rule, null, model.nowMs), () => - Arr.findFirst( - Arr.map(openIncidents, (incident) => - activeSilenceFor(silences, rule, incident.groupKey, model.nowMs), + return Option.orElse( + Option.map(activeSilenceFor(silences, rule, null, model.nowMs), (silence) => ({ + silence, + group: null, + })), + () => + Arr.head( + Arr.flatMap(openIncidents, (incident) => + Option.toArray( + Option.map( + activeSilenceFor(silences, rule, incident.groupKey, model.nowMs), + (silence) => ({ + silence, + group: incidentGroup(incident), + }), + ), + ), + ), ), - Option.isSome, - ).pipe(Option.flatten), ) } +/** `DetailHeader` (kind eyebrow, title, meta row) with the time controls and Edit rule. */ + const header = (h: H, model: Model, rule: AlertRuleDocument, timeZone: string) => { const openIncidents = Arr.filter(ruleIncidentsOf(model), (i) => i.status === "open") const isFiring = openIncidents.length > 0 @@ -89,9 +105,13 @@ const header = (h: H, model: Model, rule: AlertRuleDocument, timeZone: string) = Arr.every(openIncidents, (i) => i.holdReason != null) ? "held" : "firing", ) : alertStatusBadge(h, rule.enabled ? "ok" : "disabled"), - ...Option.toArray( - Option.map(silence, (active) => silenceBadge(h, active, model.nowMs, timeZone)), - ), + ...(AsyncData.isFailure(model.silences.data) + ? [silenceCheckFailed(h)] + : Option.toArray( + Option.map(silence, ({ silence: active, group }) => + silenceBadge(h, active, model.nowMs, timeZone, group), + ), + )), typography(h, { size: "sm", tone: "muted" }, [ruleSubtitle(rule)]), ]), ], @@ -119,7 +139,9 @@ const header = (h: H, model: Model, rule: AlertRuleDocument, timeZone: string) = }, }), }), - silenceButton(h, Message.ClickedSilence()), + ...(Policy.has(model.grants, "alerts:manage") + ? [silenceButton(h, Message.ClickedSilence())] + : []), linkButton(h, { variant: "outline", size: "sm", href: createHref({ ruleId: rule.id }) }, [ icon(h, "PencilIcon"), "Edit rule", diff --git a/apps/web/src/page/alerts/shared/silence-view.ts b/apps/web/src/page/alerts/shared/silence-view.ts index 4e9fb56674..27b524d695 100644 --- a/apps/web/src/page/alerts/shared/silence-view.ts +++ b/apps/web/src/page/alerts/shared/silence-view.ts @@ -3,6 +3,7 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { button } from "@maple/views/ui/button" import { badge } from "@maple/views/ui/display" import { icon } from "@maple/views/ui/icon" +import { typography } from "@maple/views/ui/typography" import { endsAtMs, formatSilenceTime } from "./silences" /** "Silenced until 15:00": the silence holding back a rule's or an incident's notifications. */ @@ -11,15 +12,21 @@ export const silenceBadge = ( silence: V2AlertSilence, nowMs: number, timeZone: string, + /** Names the one group silenced when the rule as a whole is not ("checkout silenced until"). */ + group: string | null = null, ): Html => badge(h, { variant: "outline", attributes: [h.Title(silence.reason)] }, [ icon(h, "BellSlashIcon"), - `Silenced until ${formatSilenceTime(endsAtMs(silence), nowMs, timeZone)}`, + `${group === null ? "Silenced" : `${group} silenced`} until ${formatSilenceTime(endsAtMs(silence), nowMs, timeZone)}`, ]) +/** Shown in place of the badge when the silences could not be read, so "no badge" never reads as "not silenced". */ +export const silenceCheckFailed = (h: HtmlBuilder): Html => + typography(h, { size: "xs", tone: "muted" }, ["Couldn't check silences"]) + /** The header's "Silence" action, which opens the silence dialog scoped to the page's rule or incident. */ -export const silenceButton = (h: HtmlBuilder, onClick: M): Html => - button(h, { variant: "outline", size: "sm", attributes: [h.OnClick(onClick)] }, [ +export const silenceButton = (h: HtmlBuilder, onClick: M, isDisabled = false): Html => + button(h, { variant: "outline", size: "sm", isDisabled, attributes: [h.OnClick(onClick)] }, [ icon(h, "BellSlashIcon"), "Silence", ]) diff --git a/apps/web/src/page/alerts/shared/silences.test.ts b/apps/web/src/page/alerts/shared/silences.test.ts index f159f15474..1297840ec8 100644 --- a/apps/web/src/page/alerts/shared/silences.test.ts +++ b/apps/web/src/page/alerts/shared/silences.test.ts @@ -37,7 +37,7 @@ const groupedRule = { } describe("activeSilenceFor", () => { - it("picks the soonest-ending silence in force, from the window not the stored status", () => { + it("picks the latest-ending silence in force, from the window not the stored status", () => { const long = silence("22222222-2222-4222-8222-222222222222", { rule_ids: [ruleId] }, -10, 120) const short = silence("33333333-3333-4333-8333-333333333333", { rule_ids: [ruleId] }, -10, 30) const ended = silence("44444444-4444-4444-8444-444444444444", { rule_ids: [ruleId] }, -60, -1) @@ -47,7 +47,7 @@ describe("activeSilenceFor", () => { activeSilenceFor([long, ended, scheduled, short], groupedRule, null, NOW), (s) => s.id, ), - ).toEqual(Option.some(short.id)) + ).toEqual(Option.some(long.id)) }) it("covers only the silenced group of a rule grouped by service", () => { @@ -61,3 +61,33 @@ describe("activeSilenceFor", () => { expect(Option.isSome(activeSilenceFor([checkout], groupedRule, "payments", NOW))).toBe(false) }) }) + +describe("matchersForRule", () => { + const rule = ( + overrides: Partial<{ groupBy: ReadonlyArray | null; serviceNames: ReadonlyArray }>, + ) => ({ + id: ruleId, + groupBy: null, + serviceNames: [], + ...overrides, + }) + + it("narrows to the incident's group the way the server matches it", () => { + expect(matchersForRule(rule({ groupBy: ["service.name"] }), "checkout")).toEqual({ + rule_ids: [ruleId], + services: ["checkout"], + }) + expect(matchersForRule(rule({ serviceNames: ["checkout", "payments"] }), "payments")).toEqual({ + rule_ids: [ruleId], + services: ["payments"], + }) + expect(matchersForRule(rule({ groupBy: ["deployment.environment"] }), "staging")).toEqual({ + rule_ids: [ruleId], + environments: ["staging"], + }) + expect(matchersForRule(rule({ groupBy: ["service.name"] }), "__total__")).toEqual({ + rule_ids: [ruleId], + }) + expect(matchersForRule(rule({ groupBy: ["http.route"] }), "/cart")).toEqual({ rule_ids: [ruleId] }) + }) +}) diff --git a/apps/web/src/page/alerts/shared/silences.ts b/apps/web/src/page/alerts/shared/silences.ts index 0407529f10..e07dea6dec 100644 --- a/apps/web/src/page/alerts/shared/silences.ts +++ b/apps/web/src/page/alerts/shared/silences.ts @@ -3,6 +3,7 @@ import { type AlertRuleDocument, type AlertSilenceId, type AlertSilenceMatchers, + UNGROUPED_GROUP_KEY, } from "@maple/domain/http" import type { V2AlertSilence, @@ -18,12 +19,28 @@ import { isoMs } from "./time" // Alert silences: the reads and writes the hub, rule and incident pages share, and how a page // tells which silence covers a rule or an incident. -/** The org's silences, latest ending first (one page; an org holds at most 200 open ones). */ -export const listSilences = Effect.gen(function* () { - const client = yield* MapleApiV2Client - const response = yield* client.alertSilences.list({ query: { limit: 100 } }) - return response.data -}).pipe(Effect.mapError(toLoadFailure)) +const listByStatus = (status?: V2AlertSilence["status"]) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + const response = yield* client.alertSilences.list({ + query: status === undefined ? { limit: 100 } : { status, limit: 100 }, + }) + return response.data + }) + +/** The hub's list: active and scheduled in full, plus the most recently ended. */ +export const listSilences = Effect.all( + [listByStatus("active"), listByStatus("scheduled"), listByStatus("ended")], + { concurrency: "unbounded" }, +).pipe( + Effect.mapBoth({ + onSuccess: ([active, scheduled, ended]) => [...active, ...scheduled, ...ended], + onFailure: toLoadFailure, + }), +) + +/** What a rule or incident page needs for its badge: only the silences in force. */ +export const listActiveSilences = listByStatus("active").pipe(Effect.mapError(toLoadFailure)) export const createSilence = (payload: V2AlertSilenceCreateParams) => Effect.gen(function* () { @@ -59,9 +76,12 @@ const toDomainMatchers = (matchers: V2AlertSilenceMatchers): AlertSilenceMatcher ...(matchers.environments === undefined ? undefined : { environments: matchers.environments }), }) -const byEnd = Order.mapInput(Order.Number, endsAtMs) +const byLatestEnd = Order.flip(Order.mapInput(Order.Number, endsAtMs)) -/** The silence in force over a rule (and one incident group of it) at `nowMs`; the soonest-ending wins. */ +/** + * The silence in force over a rule (and one incident group of it) at `nowMs`. The latest-ending + * one wins: notifications resume only when every matching silence has ended. + */ export const activeSilenceFor = ( silences: ReadonlyArray, rule: Pick, @@ -83,7 +103,7 @@ export const activeSilenceFor = ( groupKey, }), ), - byEnd, + byLatestEnd, ), ) @@ -98,14 +118,20 @@ export const silenceScopeParts = ( ...Arr.map(matchers.tags ?? [], (tag) => `tag ${tag}`), ] -/** The matchers that silence one rule, or just one service group of a rule grouped by service. */ +const groupedBy = (rule: Pick, dimension: string): boolean => + rule.groupBy !== null && rule.groupBy.length === 1 && rule.groupBy[0] === dimension + +/** + * The matchers that silence one rule, or just one incident group of it: a service group (grouped + * by service, or a multi-service rule keyed by service) or an environment group, as the server reads them. + */ export const matchersForRule = ( - rule: Pick, + rule: Pick, groupKey: string | null, -): V2AlertSilenceMatchers => - groupKey !== null && - rule.groupBy !== null && - rule.groupBy.length === 1 && - rule.groupBy[0] === "service.name" - ? { rule_ids: [rule.id], services: [groupKey] } - : { rule_ids: [rule.id] } +): V2AlertSilenceMatchers => { + if (groupKey === null || groupKey === UNGROUPED_GROUP_KEY) return { rule_ids: [rule.id] } + if (groupedBy(rule, "service.name") || rule.serviceNames.includes(groupKey)) + return { rule_ids: [rule.id], services: [groupKey] } + if (groupedBy(rule, "deployment.environment")) return { rule_ids: [rule.id], environments: [groupKey] } + return { rule_ids: [rule.id] } +} diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts index cb88eae864..bedc57bb70 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" import { Option } from "effect" -import { init, isFormReady, matchersOf, silenceEndMs } from "./dialog" +import { init, isFormReady, matchersOf, silenceEndMs, untilError } from "./dialog" // 2026-11-06 14:20 in Berlin (UTC+1). const NOW = Date.UTC(2026, 10, 6, 13, 20) @@ -12,8 +12,16 @@ describe("silenceEndMs", () => { expect(silenceEndMs("4h", "", NOW, BERLIN)).toEqual(Option.some(NOW + 4 * 60 * 60_000)) }) - it("ends at 09:00 tomorrow on the viewer's wall clock", () => { - expect(silenceEndMs("tomorrow", "", NOW, BERLIN)).toEqual(Option.some(Date.UTC(2026, 10, 7, 8, 0))) + it("ends at the next 09:00 on the viewer's wall clock", () => { + expect(silenceEndMs("morning", "", NOW, BERLIN)).toEqual(Option.some(Date.UTC(2026, 10, 7, 8, 0))) + // At 00:30 the next 09:00 is this morning's, and across a month's end it rolls over. + expect(silenceEndMs("morning", "", Date.UTC(2026, 9, 31, 23, 30), BERLIN)).toEqual( + Option.some(Date.UTC(2026, 10, 1, 8, 0)), + ) + // Across the DST change (Berlin falls back on 2026-10-25): 09:00 CET, not CEST. + expect(silenceEndMs("morning", "", Date.UTC(2026, 9, 24, 20, 0), BERLIN)).toEqual( + Option.some(Date.UTC(2026, 9, 25, 8, 0)), + ) }) it("reads a custom end in the viewer's zone and refuses one in the past", () => { @@ -54,8 +62,18 @@ describe("isFormReady", () => { reason: "deploy", scope: typed("checkout"), duration: "custom" as const, + timeZone: BERLIN, + nowMs: NOW, } expect(isFormReady({ ...model, until: "" })).toBe(false) expect(isFormReady({ ...model, until: "2026-11-07T09:00" })).toBe(true) + expect(isFormReady({ ...model, until: "2026-11-06T09:00" })).toBe(false) + expect(untilError({ ...model, until: "2026-11-06T09:00" })).toEqual( + Option.some("Pick a time in the future"), + ) + expect(isFormReady({ ...model, until: "2026-12-24T09:00" })).toBe(false) + expect(untilError({ ...model, until: "2026-12-24T09:00" })).toEqual( + Option.some("A silence can last at most 30 days"), + ) }) }) diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.ts b/apps/web/src/page/alerts/silence-dialog/dialog.ts index e5dd813e6d..504ed4f098 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.ts @@ -3,14 +3,14 @@ import { zonedDateParts, zonedPartsToEpochMs } from "@maple/query-engine/datetim import { Clock, DateTime, Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" import { Command, type Update } from "foldkit" import { defineMessageUnion } from "foldkit/message" -import { LoadFailure } from "@maple/views/data/load-failure" import * as Dialog from "@maple/views/ui/dialog" import type { Resources } from "../../../platform/resources" +import { MAX_ALERT_SILENCE_DURATION_MS } from "@maple/domain/http" import { createSilence } from "../shared/silences" // "Silence notifications": the dialog the hub, a rule and an incident open, as one Submodel. -export const SilenceDuration = Schema.Literals(["1h", "4h", "tomorrow", "custom"]) +export const SilenceDuration = Schema.Literals(["1h", "4h", "morning", "custom"]) export type SilenceDuration = typeof SilenceDuration.Type export const ScopeField = Schema.Literals(["services", "tags", "environments"]) @@ -35,6 +35,8 @@ export const Model = Schema.Struct({ /** The custom end, as a `datetime-local` input holds it (`2026-11-06T18:30`), in `timeZone`. */ until: Schema.String, timeZone: Schema.String, + /** The wall clock when the dialog opened: a custom end is checked against it. */ + nowMs: Schema.Number, isSaving: Schema.Boolean, /** Bumped on every open: a save's result only settles the form it was sent from. */ openCount: Schema.Number, @@ -48,6 +50,7 @@ export const init = (id: string): Model => ({ duration: "1h", until: "", timeZone: "UTC", + nowMs: 0, isSaving: false, openCount: 0, }) @@ -60,7 +63,7 @@ export const Message = defineMessageUnion({ ChangedScopeField: { field: ScopeField, value: Schema.String }, ClickedCancel: {}, ClickedSave: {}, - CompletedSave: { openCount: Schema.Number, result: Schema.Result(V2AlertSilence, LoadFailure) }, + CompletedSave: { openCount: Schema.Number, result: Schema.Result(V2AlertSilence, Schema.String) }, }) export type Message = typeof Message.Type @@ -95,7 +98,7 @@ export const parseLocalDateTime = (value: string, timeZone: string): Option.Opti return Option.some(zonedPartsToEpochMs({ year, month, day, hour, minute, second: 0 }, timeZone)) } -/** When a silence picked at `nowMs` ends; none for a custom end that is missing or not in the future. */ +/** When a silence picked at `nowMs` ends; none for a custom end that is missing, past, or over 30 days out. */ export const silenceEndMs = ( duration: SilenceDuration, until: string, @@ -107,17 +110,18 @@ export const silenceEndMs = ( return Option.some(nowMs + HOUR_MS) case "4h": return Option.some(nowMs + 4 * HOUR_MS) - case "tomorrow": { + case "morning": { + // The next 09:00 on the viewer's wall clock: this morning's if it is still ahead. const today = zonedDateParts(nowMs, timeZone) - return Option.some( - zonedPartsToEpochMs( - { ...today, day: today.day + 1, hour: 9, minute: 0, second: 0 }, - timeZone, - ), - ) + const at = (day: number) => + zonedPartsToEpochMs({ ...today, day, hour: 9, minute: 0, second: 0 }, timeZone) + return Option.some(at(today.day) > nowMs ? at(today.day) : at(today.day + 1)) } case "custom": - return Option.filter(parseLocalDateTime(until, timeZone), (endMs) => endMs > nowMs) + return Option.filter( + parseLocalDateTime(until, timeZone), + (endMs) => endMs > nowMs && endMs - nowMs <= MAX_ALERT_SILENCE_DURATION_MS, + ) } } @@ -144,11 +148,25 @@ const hasMatchers = (matchers: V2AlertSilenceMatchers) => (matchers.environments?.length ?? 0) > 0 -/** Ready to save: a reason, something to match and, for a custom end, a parseable time. */ +/** Why a typed custom end cannot be used, or none when it can (or nothing is typed yet). */ +export const untilError = (model: Model): Option.Option => { + if (model.duration !== "custom" || model.until === "") return Option.none() + return Option.match(parseLocalDateTime(model.until, model.timeZone), { + onNone: () => Option.some("Pick a date and time"), + onSome: (endMs) => + endMs <= model.nowMs + ? Option.some("Pick a time in the future") + : endMs - model.nowMs > MAX_ALERT_SILENCE_DURATION_MS + ? Option.some("A silence can last at most 30 days") + : Option.none(), + }) +} + +/** Ready to save: a reason, something to match and an end that is ahead and within 30 days. */ export const isFormReady = (model: Model): boolean => Str.isNonEmpty(Str.trim(model.reason)) && hasMatchers(matchersOf(model.scope)) && - (model.duration !== "custom" || Option.isSome(parseLocalDateTime(model.until, model.timeZone))) + Option.isSome(silenceEndMs(model.duration, model.until, model.nowMs, model.timeZone)) // COMMANDS @@ -165,12 +183,14 @@ export const SaveSilence = Command.define("SaveAlertSilence", { execute: ({ openCount, reason, matchers, duration, until, timeZone }) => Effect.gen(function* () { const nowMs = yield* Clock.currentTimeMillis - const endsAt = Option.getOrElse(silenceEndMs(duration, until, nowMs, timeZone), () => nowMs) + const endsAt = yield* Effect.fromOption(silenceEndMs(duration, until, nowMs, timeZone)).pipe( + Effect.mapError(() => "Pick an end time in the future"), + ) return yield* createSilence({ reason, matchers, ends_at: DateTime.formatIso(DateTime.makeUnsafe(endsAt)), - }) + }).pipe(Effect.mapError((failure) => failure.message)) }).pipe( Effect.result, Effect.map((result) => Message.CompletedSave({ openCount, result })), @@ -184,12 +204,13 @@ const liftDialog = (model: Model, result: ReturnType): Ret commands: Command.mapMessages(result.commands ?? [], (message) => Message.GotDialogMessage({ message })), }) -const open = (model: Model, scope: Scope, timeZone: string): Return => +const open = (model: Model, scope: Scope, timeZone: string, nowMs: number): Return => liftDialog( { ...model, scope, timeZone, + nowMs, reason: "", duration: "1h", until: "", @@ -204,11 +225,12 @@ export const openFor = ( model: Model, target: { readonly label: string; readonly matchers: V2AlertSilenceMatchers }, timeZone: string, -): Return => open(model, { _tag: "Fixed", ...target }, timeZone) + nowMs: number, +): Return => open(model, { _tag: "Fixed", ...target }, timeZone, nowMs) /** A silence the user scopes by service, tag and environment. */ -export const openNew = (model: Model, timeZone: string): Return => - open(model, { _tag: "Typed", services: "", tags: "", environments: "" }, timeZone) +export const openNew = (model: Model, timeZone: string, nowMs: number): Return => + open(model, { _tag: "Typed", services: "", tags: "", environments: "" }, timeZone, nowMs) const save = (model: Model): Return => model.isSaving || !isFormReady(model) @@ -254,7 +276,7 @@ export const update = (model: Model, message: Message): Return => outMessage: OutMessage.RequestedToast({ type: "error", title: "Couldn't silence alerts", - description: failure.message, + description: failure, }), }), }), diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index 1be937c2d0..3857294863 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -1,4 +1,4 @@ -import { Array as Arr } from "effect" +import { Option, Array as Arr } from "effect" import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { eyebrow } from "@maple/views/ui/eyebrow" @@ -10,23 +10,37 @@ import { segmentedSelect } from "@maple/views/ui/segmented-select" import { vstack } from "@maple/views/ui/stack" import { typography } from "@maple/views/ui/typography" import type { V2AlertSilenceMatchers } from "@maple/domain/http/v2" -import { isFormReady, Message, type Model, type ScopeField, type SilenceDuration } from "./dialog" +import { + isFormReady, + Message, + type Model, + type ScopeField, + type SilenceDuration, + silenceEndMs, + untilError, +} from "./dialog" +import { formatSilenceTime } from "../shared/silences" type H = HtmlBuilder const DURATIONS: ReadonlyArray<{ value: SilenceDuration; label: string }> = [ { value: "1h", label: "1 hour" }, { value: "4h", label: "4 hours" }, - { value: "tomorrow", label: "Until 09:00 tomorrow" }, + { value: "morning", label: "Until 09:00" }, { value: "custom", label: "Until…" }, ] -const SUBMIT_LABELS = { - "1h": "Silence for 1 hour", - "4h": "Silence for 4 hours", - tomorrow: "Silence until tomorrow", - custom: "Silence until then", -} satisfies Record +/** "Silence for 1 hour", or the end it will stop at: "Silence until Nov 7, 18:30". */ +const submitLabel = (model: Model): string => + model.duration === "1h" + ? "Silence for 1 hour" + : model.duration === "4h" + ? "Silence for 4 hours" + : Option.match(silenceEndMs(model.duration, model.until, model.nowMs, model.timeZone), { + onNone: () => "Silence", + onSome: (endMs) => + `Silence until ${formatSilenceTime(endMs, model.nowMs, model.timeZone)}`, + }) const stateOf = (value: string): FieldState => ({ isInvalid: false, @@ -136,6 +150,11 @@ const durationSection = (h: H, model: Model): Html => onInput: (value) => Message.ChangedUntil({ value }), description: `In ${model.timeZone}. At most 30 days from now.`, }), + ...Option.toArray( + Option.map(untilError(model), (message) => + typography(h, { size: "xs", tone: "destructive", as: "p" }, [message]), + ), + ), ] : []), ]) @@ -163,7 +182,7 @@ export const view = Submodel.defineView((model, }), ], onSubmit: Message.ClickedSave(), - submitLabel: SUBMIT_LABELS[model.duration], + submitLabel: submitLabel(model), isPending: model.isSaving, isSubmitDisabled: !isFormReady(model), }), From 227eba335f3348c96fc4b771a5eede605ce6b8ab Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 02:27:39 +0100 Subject: [PATCH 05/28] fix(alerts): record held-back pages under the claim; count in-flight pages as paged - The first page a silence holds back now sets silence_id / silenced_at in the claimed incident refresh, not in a separate unfenced update that a failure or a concurrent resolve could split from the silenced delivery rows. - A resolve during a silence reaches every destination with a non-silenced page, including one still queued or retrying, so a page in flight is never left open. - Silence documents decode without throwing; the v2 handler turns a bad timestamp into a 400 and counts a duration from the clamped start. --- apps/api/src/routes/v2/alert-silences.http.ts | 52 +++++++++++------- .../alerts/AlertsService.silences.test.ts | 54 +++++++++++++++++++ .../src/services/alerts/AlertsService.ts | 27 ++++------ .../services/alerts/alert-silence-store.ts | 4 +- 4 files changed, 99 insertions(+), 38 deletions(-) diff --git a/apps/api/src/routes/v2/alert-silences.http.ts b/apps/api/src/routes/v2/alert-silences.http.ts index be175c3e25..7dddbd75b6 100644 --- a/apps/api/src/routes/v2/alert-silences.http.ts +++ b/apps/api/src/routes/v2/alert-silences.http.ts @@ -19,7 +19,10 @@ import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService" import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" import { timestampMs } from "@maple/backend/platform/time" -const decodeIso = Schema.decodeUnknownSync(IsoDateTimeString) +const decodeIso = (value: string, param: string) => + Schema.decodeEffect(IsoDateTimeString)(value).pipe( + Effect.mapError(() => V2ParameterInvalid.make("Expected an ISO-8601 UTC timestamp.", { param })), + ) const silenceAuditDiff = auditDiff({ fields: ["reason", "matchers", "starts_at", "ends_at"], @@ -107,15 +110,24 @@ export const HttpV2AlertSilencesLive = HttpApiBuilder.group(MapleApiV2, "alertSi }, ) } - const startsAtMs = - payload.starts_at === undefined - ? yield* Clock.currentTimeMillis - : timestampMs(payload.starts_at) - const endsAt = + // A duration runs from when the silence takes effect: a start a little in the past + // (client clock skew) is clamped to now by the service, so count from there too. + const nowMs = yield* Clock.currentTimeMillis + const startsAtMs = Math.max( + payload.starts_at === undefined ? nowMs : timestampMs(payload.starts_at), + nowMs, + ) + const endsAt = yield* decodeIso( payload.ends_at ?? - DateTime.formatIso( - DateTime.makeUnsafe(startsAtMs + (payload.duration_minutes ?? 0) * 60_000), - ) + DateTime.formatIso( + DateTime.makeUnsafe(startsAtMs + (payload.duration_minutes ?? 0) * 60_000), + ), + payload.ends_at === undefined ? "duration_minutes" : "ends_at", + ) + const startsAt = + payload.starts_at === undefined + ? undefined + : yield* decodeIso(payload.starts_at, "starts_at") const created = yield* silences.create( tenant.orgId, tenant.userId, @@ -123,10 +135,8 @@ export const HttpV2AlertSilencesLive = HttpApiBuilder.group(MapleApiV2, "alertSi new AlertSilenceCreateRequest({ reason: payload.reason, matchers: toMatchers(payload.matchers), - ...(payload.starts_at === undefined - ? undefined - : { startsAt: decodeIso(payload.starts_at) }), - endsAt: decodeIso(endsAt), + ...(startsAt === undefined ? undefined : { startsAt }), + endsAt, }), ) yield* recordHttpAudit("alert_silence.created", { @@ -141,6 +151,14 @@ export const HttpV2AlertSilencesLive = HttpApiBuilder.group(MapleApiV2, "alertSi const tenant = yield* CurrentTenant.Context if (payload.matchers !== undefined) yield* requireMatcher(payload.matchers) const current = yield* silences.get(tenant.orgId, params.id) + const startsAt = + payload.starts_at === undefined + ? undefined + : yield* decodeIso(payload.starts_at, "starts_at") + const endsAt = + payload.ends_at === undefined + ? undefined + : yield* decodeIso(payload.ends_at, "ends_at") const updated = yield* silences.update( tenant.orgId, tenant.userId, @@ -151,12 +169,8 @@ export const HttpV2AlertSilencesLive = HttpApiBuilder.group(MapleApiV2, "alertSi ...(payload.matchers === undefined ? undefined : { matchers: toMatchers(payload.matchers) }), - ...(payload.starts_at === undefined - ? undefined - : { startsAt: decodeIso(payload.starts_at) }), - ...(payload.ends_at === undefined - ? undefined - : { endsAt: decodeIso(payload.ends_at) }), + ...(startsAt === undefined ? undefined : { startsAt }), + ...(endsAt === undefined ? undefined : { endsAt }), }), ) yield* recordHttpAudit("alert_silence.updated", { diff --git a/packages/backend/src/services/alerts/AlertsService.silences.test.ts b/packages/backend/src/services/alerts/AlertsService.silences.test.ts index 65e72e3bdc..a7c86541f4 100644 --- a/packages/backend/src/services/alerts/AlertsService.silences.test.ts +++ b/packages/backend/src/services/alerts/AlertsService.silences.test.ts @@ -269,6 +269,60 @@ describe("alert silences", () => { }) }) +describe("alert silences, pages in flight and reminders", () => { + it.effect("sends the all-clear to a destination whose pre-silence trigger is still retrying", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRateRows(10) as ReadonlyArray> } + // The webhook is down for the whole test: the trigger stays queued for a retry. + const fetchImpl = (async () => new Response("down", { status: 503 })) as typeof fetch + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const alerts = yield* AlertsService + yield* createRule(alerts) + yield* ticks(alerts, 2) + yield* silence({ services: ["checkout"] }) + + state.tracesAggregateRows = errorRateRows(0.5) + yield* ticks(alerts, 2) + + const events = (yield* alerts.listDeliveryEvents(orgId)).events + const resolves = Arr.filter(events, (event) => event.eventType === "resolve") + assert.isNotEmpty(resolves) + assert.isTrue(Arr.every(resolves, (event) => event.status !== "silenced")) + }).pipe(Effect.provide(makeLayer(testDb, makeWarehouseStub(state), { fetch: fetchImpl }))) + }) + + it.effect("owes a held-back reminder and sends it as a reminder once the silence ends", () => { + const { sent, layer } = setup() + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const alerts = yield* AlertsService + const silences = yield* AlertSilencesService + yield* createRule(alerts) + yield* ticks(alerts, 2) + const created = yield* silence({ services: ["checkout"] }, 120) + const before = (yield* alerts.listIncidents(orgId)).incidents[0] + + // Past the 30-minute reminder: it is held back and owed, and moves no anchor. + yield* ticks(alerts, 35) + const held = (yield* alerts.listIncidents(orgId)).incidents[0] + assert.strictEqual(held?.silenceId, created.id) + assert.isNotNull(held?.silencedAt) + assert.strictEqual(held?.lastNotifiedAt, before?.lastNotifiedAt) + + yield* silences.end(orgId, userId, adminRoles, created.id) + yield* ticks(alerts, 2) + assert.deepStrictEqual(yield* deliveries(alerts), [ + "trigger:success", + "renotify:silenced", + "renotify:success", + ]) + assert.deepStrictEqual(sent, ["trigger", "renotify"]) + assert.isNull((yield* alerts.listIncidents(orgId)).incidents[0]?.silencedAt) + }).pipe(Effect.provide(layer)) + }) +}) + describe("AlertSilencesService", () => { it.effect("validates the window, the rule ids and the caller's role", () => { const { layer } = setup() diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index 758ce2b816..e42e1cdbda 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -1382,10 +1382,11 @@ export class AlertsService extends Context.Service row.destinationId), ]) if (destinationIds.length === 0) return [] - // Under a silence, a resolve still reaches whoever was paged before it began. + // Under a silence, a resolve still reaches whoever was paged, or is still being paged + // (a queued or retrying trigger), before it began. const pagedDestinationIds = new Set( priorDeliveries - .filter((row) => row.status === "success" && row.eventType !== "resolve") + .filter((row) => row.status !== "silenced" && row.eventType !== "resolve") .map((row) => row.destinationId), ) const silenceFor = (destinationId: AlertDestinationId): AlertSilenceId | null => @@ -1563,7 +1564,8 @@ export class AlertsService extends Context.Service 0) yield* dbExecute((db) => insertIncidentDeliveries(db, deliveries)) - if (silence !== null) { - yield* dbExecute((db) => - db.run( - PG.update(AlertIncidents) - .set({ - silenceId: silence.id, - ...(eventType === "resolve" - ? undefined - : { silencedAt: incident.silencedAt ?? scheduledAt }), - }) - .where(($) => [$.orgId.eq(orgId), $.id.eq(incident.id)]), - ), - ) - } if (silenceId !== null) { yield* Metric.update(AlertingMetrics.notificationsSilencedTotal, 1) yield* Effect.logInfo("Alert notification held back by a silence").pipe( @@ -3409,6 +3397,11 @@ export class AlertsService extends Context.Service [$.id.eq(openIncident.id), $.status.eq("open")]) .returning("id"), diff --git a/packages/backend/src/services/alerts/alert-silence-store.ts b/packages/backend/src/services/alerts/alert-silence-store.ts index a1edde91a3..7830d59e32 100644 --- a/packages/backend/src/services/alerts/alert-silence-store.ts +++ b/packages/backend/src/services/alerts/alert-silence-store.ts @@ -13,7 +13,7 @@ import type { MapleDb } from "@maple/db/client" import { Array as Arr, Option, Order, Schema } from "effect" const decodeMatchers = Schema.decodeUnknownOption(AlertSilenceMatchers) -const decodeDocument = Schema.decodeUnknownSync(AlertSilenceDocument) +const decodeDocument = Schema.decodeUnknownOption(AlertSilenceDocument) const silenceEndOrder = Order.mapInput(Order.Number, (row: StoredSilenceRow) => row.endsAt) @@ -33,7 +33,7 @@ export const silenceRowToDocument = ( row: StoredSilenceRow, nowMs: number, ): Option.Option => - Option.map(silenceRowMatchers(row), (matchers) => + Option.flatMap(silenceRowMatchers(row), (matchers) => decodeDocument({ id: row.id, reason: row.reason, From 6a157813136285b8cc68e8407c49066d50e84720 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 02:41:55 +0100 Subject: [PATCH 06/28] chore(domain): regenerate anticipated error identifiers for AlertSilenceNotFoundError --- packages/domain/src/generated/anticipated-error-identifiers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/domain/src/generated/anticipated-error-identifiers.ts b/packages/domain/src/generated/anticipated-error-identifiers.ts index ae211d5656..bea2f99b25 100644 --- a/packages/domain/src/generated/anticipated-error-identifiers.ts +++ b/packages/domain/src/generated/anticipated-error-identifiers.ts @@ -22,6 +22,7 @@ export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray = [ "@maple/http/errors/AlertRecipientSelectionError", "@maple/http/errors/AlertRuleDestinationNotFoundError", "@maple/http/errors/AlertRuleNotFoundError", + "@maple/http/errors/AlertSilenceNotFoundError", "@maple/http/errors/AlertValidationError", "@maple/http/errors/ApiKeyNotFoundError", "@maple/http/errors/AwsAccountAlreadyConnectedError", From c3f381784d197ab90604021ddb80998a370fa67e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 02:43:19 +0100 Subject: [PATCH 07/28] fix(ai): list_alert_silences keeps scheduled silences in view behind active ones --- .../__tests__/alert-silence-tools.test.ts | 17 +++++++++++++- apps/ai/src/mcp/tools/list-alert-silences.ts | 23 +++++++++++++++---- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts index 377e2dd716..3079cb2ebd 100644 --- a/apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts @@ -50,6 +50,8 @@ interface Seen { created?: AlertSilenceCreateRequest listedStatuses: Array ended?: string + /** More silences beside `stored`, for one test. */ + extra?: ReadonlyArray } const layer = (seen: Seen) => @@ -66,7 +68,7 @@ const layer = (seen: Seen) => return Effect.succeed( new AlertSilencesListResponse({ silences: Arr.filter( - stored, + [...stored, ...(seen.extra ?? [])], (s) => options.status === undefined || s.status === options.status, ), }), @@ -238,6 +240,19 @@ describe("alert silence tools", () => { expect(text(ended)).toContain("Old outage") }) + it("list_alert_silences keeps scheduled silences in view behind a full page of active ones", async () => { + const seen: Seen = { + ...fresh(), + extra: Arr.map(["d", "e", "f"], (prefix) => + silence(`${prefix}1111111-2222-4333-8444-555555555555`, "active", `Deploy ${prefix}`), + ), + } + const result = await ok("list_alert_silences", { limit: 2 }, seen) + const output = Schema.decodeUnknownSync(ListAlertSilencesOutput)(result.structuredContent) + expect(Arr.map(output.silences, (s) => s.status)).toEqual(["active", "scheduled"]) + expect(output.hasMore).toBe(true) + }) + it("end_alert_silence ends by id and maps an unknown one to silence_id", async () => { const seen = fresh() const result = await ok("end_alert_silence", { silence_id: ACTIVE_ID }, seen) diff --git a/apps/ai/src/mcp/tools/list-alert-silences.ts b/apps/ai/src/mcp/tools/list-alert-silences.ts index 42cd23fa38..a3b2702eec 100644 --- a/apps/ai/src/mcp/tools/list-alert-silences.ts +++ b/apps/ai/src/mcp/tools/list-alert-silences.ts @@ -38,8 +38,15 @@ export function registerListAlertSilencesTool(server: McpToolRegistrar) { .list(tenant.orgId, { status, limit: params.limit + 1 }) .pipe(Effect.mapError(toMcpHttpError("list_alert_silences"))), ) - const all = Arr.flatMap(pages, (page) => page.silences) - const shown = all.slice(0, params.limit) + // Both statuses share the window: a full page of active silences must not hide every + // scheduled one, so scheduled keeps at least half of it when it has that many. + const [first = [], second = []] = Arr.map(pages, (page) => page.silences) + const secondShare = Math.min( + second.length, + Math.max(params.limit - first.length, Math.floor(params.limit / 2)), + ) + const shown = [...first.slice(0, params.limit - secondShare), ...second.slice(0, secondShare)] + const total = first.length + second.length yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId, @@ -52,7 +59,7 @@ export function registerListAlertSilencesTool(server: McpToolRegistrar) { total: shown.length, ...(params.status === undefined ? undefined : { status: params.status }), limit: params.limit, - hasMore: all.length > params.limit, + hasMore: total > shown.length, } }), render: (output) => ({ @@ -99,7 +106,15 @@ export function registerListAlertSilencesTool(server: McpToolRegistrar) { "more silences", ), } - : undefined), + : output.status === undefined + ? { + next: doc.next( + "list_alert_silences", + { status: "scheduled", limit: MAX_LIMIT }, + "the scheduled silences on their own", + ), + } + : undefined), }, } : undefined), From fddf07685002562fda2a4d1169c3c1c8dddf4901 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 02:44:47 +0100 Subject: [PATCH 08/28] chore(ios): regenerate the OpenAPI spec for alert silences --- .../MapleAPI/Sources/MapleAPI/openapi.json | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index 8d39db0bea..09ac6aeaa0 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -324,6 +324,8 @@ "rule_name": "Checkout error rate", "severity": "critical", "signal_type": "error_rate", + "silence_id": null, + "silenced_at": null, "status": "open", "threshold": 0.05, "threshold_upper": null @@ -418,6 +420,14 @@ "signal_type": { "$ref": "#/components/schemas/_maple_AlertSignalType" }, + "silence_id": { + "$ref": "#/components/schemas/_maple_AlertSilenceId", + "description": "The alert silence (`sil_…`) that last held back one of this incident's notifications, or `null`." + }, + "silenced_at": { + "description": "Set while a silenced notification is still owed: the first breaching check after the silence ends sends it and clears this. `null` otherwise.", + "type": "string" + }, "status": { "$ref": "#/components/schemas/_maple_AlertIncidentStatus" }, @@ -6808,7 +6818,8 @@ "queued", "processing", "success", - "failed" + "failed", + "silenced" ], "title": "Alert Delivery Status", "type": "string" @@ -6979,6 +6990,15 @@ "title": "Alert Signal Type", "type": "string" }, + "_maple_AlertSilenceId": { + "description": "Opaque, prefixed public object ID (e.g. `sil_YofPTrK9782DWwcnXhpcCw`). A reversible base58 encoding of the internal ID — treat it as an opaque string.", + "examples": [ + "sil_YofPTrK9782DWwcnXhpcCw" + ], + "format": "maple.public_id", + "title": "Public ID", + "type": "string" + }, "_maple_AlertSkipReason": { "description": "Set on `skipped` points: `no_data` (the window had no data), `below_min_samples`, or `no_value`.", "enum": [ @@ -13987,6 +14007,10 @@ "description": "The incident history produced by your alert rules. Incidents open when a rule breaches for enough consecutive checks and resolve automatically once the signal recovers — this surface is read-only.", "name": "Alert Incidents" }, + { + "description": "Maintenance windows for alerting: quiet a deploy or a planned migration without disabling rules. Rules keep evaluating and incidents still open and resolve; only notifications are held back.", + "name": "Alert Silences" + }, { "description": "Telemetry ingest credentials for your organization: a public key for client-side senders and a private key for server-side senders. Retrieve them or roll either key. All operations are admin-only.", "name": "Ingest Keys" From 8a9aee69192ca787cea0a32ae57dc1e33f5031dc Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 02:47:23 +0100 Subject: [PATCH 09/28] fix(alerts): purge silences with their org; exact silence durations - alert_silences is registered as org-scoped, so deleting an org purges it. - The v2 create always sends the start it counted a duration from, and the service keeps a start within its clock-skew allowance as given instead of clamping it to a later now, so duration_minutes=60 is exactly an hour. --- apps/api/src/routes/v2/alert-silences.http.ts | 19 ++++++++----------- .../services/alerts/AlertSilencesService.ts | 3 ++- .../src/services/org/OrganizationService.ts | 2 ++ 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/api/src/routes/v2/alert-silences.http.ts b/apps/api/src/routes/v2/alert-silences.http.ts index 7dddbd75b6..8918a081f6 100644 --- a/apps/api/src/routes/v2/alert-silences.http.ts +++ b/apps/api/src/routes/v2/alert-silences.http.ts @@ -110,13 +110,10 @@ export const HttpV2AlertSilencesLive = HttpApiBuilder.group(MapleApiV2, "alertSi }, ) } - // A duration runs from when the silence takes effect: a start a little in the past - // (client clock skew) is clamped to now by the service, so count from there too. + // The start is always sent, so a duration counts from exactly the start the silence gets. const nowMs = yield* Clock.currentTimeMillis - const startsAtMs = Math.max( - payload.starts_at === undefined ? nowMs : timestampMs(payload.starts_at), - nowMs, - ) + const startsAtMs = + payload.starts_at === undefined ? nowMs : timestampMs(payload.starts_at) const endsAt = yield* decodeIso( payload.ends_at ?? DateTime.formatIso( @@ -124,10 +121,10 @@ export const HttpV2AlertSilencesLive = HttpApiBuilder.group(MapleApiV2, "alertSi ), payload.ends_at === undefined ? "duration_minutes" : "ends_at", ) - const startsAt = - payload.starts_at === undefined - ? undefined - : yield* decodeIso(payload.starts_at, "starts_at") + const startsAt = yield* decodeIso( + payload.starts_at ?? DateTime.formatIso(DateTime.makeUnsafe(startsAtMs)), + "starts_at", + ) const created = yield* silences.create( tenant.orgId, tenant.userId, @@ -135,7 +132,7 @@ export const HttpV2AlertSilencesLive = HttpApiBuilder.group(MapleApiV2, "alertSi new AlertSilenceCreateRequest({ reason: payload.reason, matchers: toMatchers(payload.matchers), - ...(startsAt === undefined ? undefined : { startsAt }), + startsAt, endsAt, }), ) diff --git a/packages/backend/src/services/alerts/AlertSilencesService.ts b/packages/backend/src/services/alerts/AlertSilencesService.ts index eeb29368dc..1c1100a05d 100644 --- a/packages/backend/src/services/alerts/AlertSilencesService.ts +++ b/packages/backend/src/services/alerts/AlertSilencesService.ts @@ -209,7 +209,8 @@ export class AlertSilencesService extends Context.Service = [ AlertIncidents, AlertRuleStates, AlertRules, + AlertSilences, // Before its destination: a thread row references it. AlertLinearIssues, AlertDestinations, From 8ce2686783be1e048bfd0921e33d96f481f4156e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 02:49:07 +0100 Subject: [PATCH 10/28] fix(web): name environment groups in the silence dialog; cancel copy; rule names - The dialog names an environment group ("Only the staging group of this rule"), not just a service one. - Cancelling a scheduled silence confirms "Silence cancelled". - A silence row leaves rule ids out of its scope until rules have synced, instead of reading "rule rule". --- .../alerts/hub/hub-silences.scene.test.ts | 14 ++++++++++ apps/web/src/page/alerts/hub/update.ts | 9 +++++-- apps/web/src/page/alerts/hub/view-silences.ts | 26 ++++++++++++++----- .../src/page/alerts/silence-dialog/view.ts | 6 ++--- 4 files changed, 44 insertions(+), 11 deletions(-) diff --git a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts index 074e18639e..c5e20a8288 100644 --- a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts @@ -126,6 +126,20 @@ describe("alerts hub, Silences tab", () => { ) }) + test("confirms a cancelled scheduled silence as cancelled, an ended active one as ended", () => { + const id = asSilenceId("22222222-2222-4222-8222-222222222222") + const model = onSilences(ENGINEER, [silence({ startMin: 60, endMin: 120 })]) + const toastOf = (result: V2AlertSilence) => + pageUpdate(model, Message.CompletedEndSilence({ silenceId: id, result: Result.succeed(result) })) + .outMessage + expect(toastOf(silence({ startMin: 0, endMin: 0, status: "ended" }))).toMatchObject({ + title: "Silence cancelled", + }) + expect(toastOf(silence({ startMin: -30, endMin: 0, status: "ended" }))).toMatchObject({ + title: "Silence ended", + }) + }) + test("New silence opens the dialog with typed scope fields", () => { scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 74b55ea715..43c294f0f5 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -15,6 +15,7 @@ import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" import * as SilenceDialog from "../silence-dialog/dialog" +import { endsAtMs, startsAtMs } from "../shared/silences" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" import { alertsHref, createHref, ruleHref } from "../shared/links" import { editMonitorHref, newMonitorHref, UPTIME_LIST_LIMIT } from "../shared/uptime-commands" @@ -621,9 +622,13 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedEndSilence: ({ silenceId, result }) => { const settled = { ...model, endingSilenceIds: withoutId(model.endingSilenceIds, silenceId) } return Result.match(result, { - onSuccess: (): Return => ({ + // A scheduled silence comes back cancelled: its window collapses to one instant. + onSuccess: (ended): Return => ({ ...refreshSilences(settled, context), - outMessage: toast("success", "Silence ended"), + outMessage: toast( + "success", + startsAtMs(ended) === endsAtMs(ended) ? "Silence cancelled" : "Silence ended", + ), }), onFailure: (failure): Return => ({ model: settled, diff --git a/apps/web/src/page/alerts/hub/view-silences.ts b/apps/web/src/page/alerts/hub/view-silences.ts index 6b40b502b8..c545a87573 100644 --- a/apps/web/src/page/alerts/hub/view-silences.ts +++ b/apps/web/src/page/alerts/hub/view-silences.ts @@ -60,7 +60,12 @@ const silenceRow = ( h: H, model: Model, silence: V2AlertSilence, - options: Readonly<{ nowMs: number; timeZone: string; ruleName: (id: string) => string }>, + options: Readonly<{ + nowMs: number + timeZone: string + ruleName: (id: string) => string + rulesLoaded: boolean + }>, ): Html => { const status = silenceStatusAt(silence, options.nowMs) const isEnding = model.endingSilenceIds.includes(silence.id) @@ -71,7 +76,11 @@ const silenceRow = ( meta: Arr.join( [ windowText(silence, options.nowMs, options.timeZone), - ...silenceScopeParts(silence.matchers, options.ruleName), + // Rule names wait for the rules to sync rather than read as a bare "rule". + ...silenceScopeParts( + options.rulesLoaded ? silence.matchers : { ...silence.matchers, rule_ids: [] }, + options.ruleName, + ), ], " · ", ), @@ -100,7 +109,12 @@ const section = ( model: Model, title: string, silences: ReadonlyArray, - options: Readonly<{ nowMs: number; timeZone: string; ruleName: (id: string) => string }>, + options: Readonly<{ + nowMs: number + timeZone: string + ruleName: (id: string) => string + rulesLoaded: boolean + }>, ): ReadonlyArray => silences.length === 0 ? [] @@ -123,13 +137,13 @@ const loaded = ( timeZone: string, ): Html => { const rules = model.rules ?? [] - const ruleName = (id: string) => + const ruleName = (id: string): string => Option.getOrElse( Option.map( Arr.findFirst(rules, (rule) => rule.id === id), (rule) => rule.name, ), - () => (model.rules === null ? "rule" : "deleted rule"), + () => "deleted rule", ) const byStatus = (status: V2AlertSilence["status"]) => Arr.filter(silences, (silence) => silenceStatusAt(silence, nowMs) === status) @@ -137,7 +151,7 @@ const loaded = ( const active = byStatus("active") const scheduled = byStatus("scheduled") if (active.length + scheduled.length + ended.length === 0) return silencesEmptyState(h) - const options = { nowMs, timeZone, ruleName } + const options = { nowMs, timeZone, ruleName, rulesLoaded: model.rules !== null } return vstack(h, { gap: "6" }, [ ...section(h, model, "Active", active, options), ...section(h, model, "Scheduled", scheduled, options), diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index 3857294863..339a1acdf8 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -99,9 +99,9 @@ const SCOPE_FIELDS: ReadonlyArray<{ field: ScopeField; label: string; placeholde /** What a scope fixed by a rule or an incident covers, in a sentence. */ const fixedScopeLine = (matchers: V2AlertSilenceMatchers): string => { - const services = matchers.services ?? [] - return services.length > 0 - ? `Only the ${Arr.join(services, ", ")} group of this rule` + const groups = [...(matchers.services ?? []), ...(matchers.environments ?? [])] + return groups.length > 0 + ? `Only the ${Arr.join(groups, ", ")} group of this rule` : "Every notification of this rule" } From 80004c5a3f724880b7ef8fdf9711a035fbfdb7d4 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 02:54:28 +0100 Subject: [PATCH 11/28] fix(alerts): match silences on a builder query's grouping; commit catch-up rows with the refresh - A builder_query rule that groups through its draft (rule-level groupBy null) now matches a service or environment silence per group: alertSilenceGroupBy reads the draft grouping and canonicalises service_name / deployment_environment. - The catch-up page's delivery rows are prepared before, and inserted in, the same claimed write that clears silenced_at, so a failure cannot lose the owed page. - Drop the silence fields wrongly added to an anomaly fixture. --- .../routes/v2/phase1-resources.http.test.ts | 2 - .../src/services/alerts/AlertsService.ts | 132 +++++++++++------- .../domain/src/http/alert-silences.test.ts | 42 ++++++ packages/domain/src/http/alert-silences.ts | 27 ++++ 4 files changed, 152 insertions(+), 51 deletions(-) diff --git a/apps/api/src/routes/v2/phase1-resources.http.test.ts b/apps/api/src/routes/v2/phase1-resources.http.test.ts index 5be189aa1a..8ab0590407 100644 --- a/apps/api/src/routes/v2/phase1-resources.http.test.ts +++ b/apps/api/src/routes/v2/phase1-resources.http.test.ts @@ -201,8 +201,6 @@ const anomalyFixture = new AnomalyIncidentDocument({ deploymentEnv: "production", fingerprintHash: null, errorIssueId: null, - silenceId: null, - silencedAt: null, status: "open", severity: "critical", openedValue: 0.12, diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index e42e1cdbda..1d905c7532 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -62,6 +62,7 @@ import { type AlertRuleUpsertRequest, type AlertGroupBy, UNGROUPED_GROUP_KEY, + alertSilenceGroupBy, OrgId, AlertRuleId, type AlertDestinationId, @@ -1515,7 +1516,7 @@ export class AlertsService extends Context.Service - db.run( - PG.update(AlertIncidents) - .set({ - // A rule edit can remap the severity without a transition. - ...(severity !== openIncident.severity - ? { - severity, - peakSeverity, - threshold: tierRule.threshold, - thresholdUpper: tierRule.thresholdUpper, - } - : undefined), - lastTriggeredAt: timestamp, - lastObservedValue: evaluation.value, - lastSampleCount: evaluation.sampleCount, - lastEvaluatedAt: timestamp, - holdReason: null, - heldSince: null, - updatedAt: timestamp, - // A silenced reminder moves no anchor: nobody heard it. - lastNotifiedAt: - (lifecycle.advanceNotificationAnchor && silence === null) || - catchUp - ? timestamp - : openIncident.lastNotifiedAt, - ...(catchUp ? { silencedAt: null } : undefined), - // The first page this silence holds back is owed from now on; written - // with the refresh, under the claim, so it cannot be lost apart from it. - ...(silence !== null && planned !== null && !owed - ? { silenceId: silence.id, silencedAt: timestamp } - : undefined), - }) - .where(($) => [$.id.eq(openIncident.id), $.status.eq("open")]) - .returning("id"), - ), + db + .run( + PG.update(AlertIncidents) + .set({ + // A rule edit can remap the severity without a transition. + ...(severity !== openIncident.severity + ? { + severity, + peakSeverity, + threshold: tierRule.threshold, + thresholdUpper: tierRule.thresholdUpper, + } + : undefined), + lastTriggeredAt: timestamp, + lastObservedValue: evaluation.value, + lastSampleCount: evaluation.sampleCount, + lastEvaluatedAt: timestamp, + holdReason: null, + heldSince: null, + updatedAt: timestamp, + // A silenced reminder moves no anchor: nobody heard it. + lastNotifiedAt: + (lifecycle.advanceNotificationAnchor && silence === null) || + catchUp + ? timestamp + : openIncident.lastNotifiedAt, + ...(catchUp ? { silencedAt: null } : undefined), + // The first page this silence holds back is owed from now on; written + // with the refresh, under the claim, so it cannot be lost apart from it. + ...(silence !== null && planned !== null && !owed + ? { silenceId: silence.id, silencedAt: timestamp } + : undefined), + }) + .where(($) => [$.id.eq(openIncident.id), $.status.eq("open")]) + .returning("id"), + ) + .pipe( + Effect.tap((rows) => + rows.length === 0 || catchUpDeliveries.length === 0 + ? Effect.void + : insertIncidentDeliveries(db, catchUpDeliveries), + ), + ), ) if (refreshed.length === 0) { return { @@ -3430,19 +3453,30 @@ export class AlertsService extends Context.Service { expect(alertSilenceStatusAt({ startsAtMs: 1_000, endsAtMs: 2_000 }, now)).toBe(status) }) }) + +describe("alertSilenceGroupBy", () => { + it("reads a builder query's draft grouping, in canonical spelling", () => { + const draft = (groupBy: ReadonlyArray, on = true) => ({ addOns: { groupBy: on }, groupBy }) + expect( + alertSilenceGroupBy({ + groupBy: null, + signalType: "builder_query", + queryBuilderDraft: draft(["service_name"]), + }), + ).toEqual(["service.name"]) + expect( + alertSilenceGroupBy({ + groupBy: null, + signalType: "builder_query", + queryBuilderDraft: draft(["service.name"], false), + }), + ).toBeNull() + expect( + alertSilenceGroupBy({ + groupBy: ["deployment.environment"], + signalType: "error_rate", + queryBuilderDraft: null, + }), + ).toEqual(["deployment.environment"]) + }) + + it("lets a service silence reach the service group of a builder query", () => { + const groupBy = alertSilenceGroupBy({ + groupBy: null, + signalType: "builder_query", + queryBuilderDraft: { addOns: { groupBy: true }, groupBy: ["service_name"] }, + }) + expect(alertSilenceMatches({ services: ["checkout"] }, rule({ groupBy, groupKey: "checkout" }))).toBe( + true, + ) + expect(alertSilenceMatches({ services: ["checkout"] }, rule({ groupBy, groupKey: "payments" }))).toBe( + false, + ) + }) +}) diff --git a/packages/domain/src/http/alert-silences.ts b/packages/domain/src/http/alert-silences.ts index 856f2fb9b3..fe3b6338a0 100644 --- a/packages/domain/src/http/alert-silences.ts +++ b/packages/domain/src/http/alert-silences.ts @@ -106,6 +106,33 @@ export const alertSilenceStatusAt = ( ): AlertSilenceStatus => nowMs < window.startsAtMs ? "scheduled" : nowMs < window.endsAtMs ? "active" : "ended" +/** Group-by spellings the query builder accepts for the dimensions a silence matches on. */ +const GROUP_BY_ALIASES = new Map([ + ["service_name", "service.name"], + ["deployment_environment", "deployment.environment"], +]) + +/** + * The grouping an incident's group key comes from: the rule's own, or for a builder query + * that groups through its draft, the draft's, in canonical spelling. + */ +export const alertSilenceGroupBy = (rule: { + readonly groupBy: AlertGroupBy | null + readonly signalType: string + readonly queryBuilderDraft: { + readonly addOns?: { readonly groupBy?: boolean } | undefined + readonly groupBy?: ReadonlyArray | null | undefined + } | null +}): AlertGroupBy | null => { + const keys = + rule.groupBy !== null && rule.groupBy.length > 0 + ? rule.groupBy + : rule.signalType === "builder_query" && rule.queryBuilderDraft?.addOns?.groupBy === true + ? (rule.queryBuilderDraft.groupBy ?? []) + : [] + return keys.length === 0 ? null : Arr.map(keys, (key) => GROUP_BY_ALIASES.get(key) ?? key) +} + /** The parts of a rule and incident group a silence is matched against. */ export interface AlertSilenceTarget { readonly ruleId: AlertRuleId From 7be3a4e2025f1310b48b72225d42623e79ef2514 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 02:55:09 +0100 Subject: [PATCH 12/28] fix(web): match silences on a builder query's draft grouping, like the server --- .../src/page/alerts/shared/silences.test.ts | 4 ++++ apps/web/src/page/alerts/shared/silences.ts | 18 +++++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/web/src/page/alerts/shared/silences.test.ts b/apps/web/src/page/alerts/shared/silences.test.ts index 1297840ec8..075271638d 100644 --- a/apps/web/src/page/alerts/shared/silences.test.ts +++ b/apps/web/src/page/alerts/shared/silences.test.ts @@ -34,6 +34,8 @@ const groupedRule = { serviceNames: [], environments: [], groupBy: ["service.name"], + signalType: "error_rate" as const, + queryBuilderDraft: null, } describe("activeSilenceFor", () => { @@ -69,6 +71,8 @@ describe("matchersForRule", () => { id: ruleId, groupBy: null, serviceNames: [], + signalType: "error_rate" as const, + queryBuilderDraft: null, ...overrides, }) diff --git a/apps/web/src/page/alerts/shared/silences.ts b/apps/web/src/page/alerts/shared/silences.ts index e07dea6dec..7057d101e9 100644 --- a/apps/web/src/page/alerts/shared/silences.ts +++ b/apps/web/src/page/alerts/shared/silences.ts @@ -1,4 +1,5 @@ import { + alertSilenceGroupBy, alertSilenceMatches, type AlertRuleDocument, type AlertSilenceId, @@ -84,7 +85,10 @@ const byLatestEnd = Order.flip(Order.mapInput(Order.Number, endsAtMs)) */ export const activeSilenceFor = ( silences: ReadonlyArray, - rule: Pick, + rule: Pick< + AlertRuleDocument, + "id" | "tags" | "serviceNames" | "environments" | "groupBy" | "signalType" | "queryBuilderDraft" + >, groupKey: string | null, nowMs: number, ): Option.Option => @@ -99,7 +103,7 @@ export const activeSilenceFor = ( tags: rule.tags, serviceNames: rule.serviceNames, environments: rule.environments, - groupBy: rule.groupBy, + groupBy: alertSilenceGroupBy(rule), groupKey, }), ), @@ -118,15 +122,19 @@ export const silenceScopeParts = ( ...Arr.map(matchers.tags ?? [], (tag) => `tag ${tag}`), ] -const groupedBy = (rule: Pick, dimension: string): boolean => - rule.groupBy !== null && rule.groupBy.length === 1 && rule.groupBy[0] === dimension +type GroupedRule = Pick + +const groupedBy = (rule: GroupedRule, dimension: string): boolean => { + const groupBy = alertSilenceGroupBy(rule) + return groupBy !== null && groupBy.length === 1 && groupBy[0] === dimension +} /** * The matchers that silence one rule, or just one incident group of it: a service group (grouped * by service, or a multi-service rule keyed by service) or an environment group, as the server reads them. */ export const matchersForRule = ( - rule: Pick, + rule: Pick & GroupedRule, groupKey: string | null, ): V2AlertSilenceMatchers => { if (groupKey === null || groupKey === UNGROUPED_GROUP_KEY) return { rule_ids: [rule.id] } From bcc1b8210531e8f1fc331c88a6552d26fd963107 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 02:57:55 +0100 Subject: [PATCH 13/28] test(web): incident page Silence gating and group-scoped target --- .../alerts/incident/incident.scene.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/apps/web/src/page/alerts/incident/incident.scene.test.ts b/apps/web/src/page/alerts/incident/incident.scene.test.ts index 85b8e1ffdd..582d3b7913 100644 --- a/apps/web/src/page/alerts/incident/incident.scene.test.ts +++ b/apps/web/src/page/alerts/incident/incident.scene.test.ts @@ -1,4 +1,5 @@ import { OrgId } from "@maple/domain" +import * as Policy from "@maple/domain/policy" import { AlertRulePreviewResponse } from "@maple/domain/http" import { Result, Schema, Array as Arr } from "effect" import { @@ -312,3 +313,56 @@ describe("alert incident", () => { ) }) }) + +describe("alert incident, silences", () => { + const loaded = (roles: ReadonlyArray, withRules = true) => + Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow({ group_key: "checkout" })] }), + ...(withRules + ? [ + Message.UpdatedRules({ + orgId: ORG, + rows: [ruleRow(2, { group_by: JSON.stringify(["service.name"]) })], + }), + ] + : []), + Message.CompletedFetchSession({ grants: Policy.grantsOf(roles) }), + ], + init(route(), context).model, + (model, message) => step(model, message).model, + ) + + test("Silence on a service-grouped rule's incident silences only that group", () => { + const opened = step(loaded(["org:engineer"]), Message.ClickedSilence()).model + expect(opened.silenceDialog.scope).toEqual({ + _tag: "Fixed", + label: "Rule 2 · checkout", + matchers: { rule_ids: [ruleRow(2).id], services: ["checkout"] }, + }) + }) + + test("offers Silence to managers once the rule is known, and never to members", () => { + scene( + { update: pageUpdate, view }, + given(loaded(["org:engineer"])), + themeResolved(), + expectView(role("button", { name: "Silence" })).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(loaded(["org:engineer"], false)), + themeResolved(), + expectView(role("button", { name: "Silence" })).toBeDisabled(), + ) + scene( + { update: pageUpdate, view }, + given(loaded(["org:member"])), + themeResolved(), + expectView(role("button", { name: "Silence" })).not.toExist(), + ) + // Before rules load a click does nothing, so the group can't be silenced as the whole rule. + expect(step(loaded(["org:engineer"], false), Message.ClickedSilence()).commands ?? []).toEqual([]) + }) +}) From 7b6b193ae96a38dbce3c9af2f8ba3bd5079536cb Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 02:59:52 +0100 Subject: [PATCH 14/28] test(ai): create_alert_silence with ends_at alone and a non-positive duration --- .../__tests__/alert-silence-tools.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts index 3079cb2ebd..8bf4d3b0a7 100644 --- a/apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts @@ -228,6 +228,31 @@ describe("alert silence tools", () => { expect(seen.created).toBeUndefined() }) + it("create_alert_silence passes an ends_at alone through, starting now", async () => { + const seen = fresh() + await ok( + "create_alert_silence", + { reason: "Migration", environments: ["production"], ends_at: "2030-01-01 06:00:00" }, + seen, + ) + expect(seen.created?.matchers).toEqual({ environments: ["production"] }) + expect(seen.created?.startsAt).toBeUndefined() + expect(seen.created?.endsAt).toBe("2030-01-01T06:00:00.000Z") + }) + + it("create_alert_silence refuses a duration that is not positive", async () => { + const seen = fresh() + for (const duration_minutes of [0, -15]) { + const result = await run( + "create_alert_silence", + { reason: "x", tags: ["a"], duration_minutes }, + seen, + ) + expect(invalidParameter(result)).toBe("duration_minutes") + } + expect(seen.created).toBeUndefined() + }) + it("list_alert_silences shows active and scheduled silences by default, not ended ones", async () => { const seen = fresh() const result = await ok("list_alert_silences", {}, seen) From e3988fedd1da7e4833105de838763ac42e239ce9 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 03:02:01 +0100 Subject: [PATCH 15/28] fix(web): the incident page shows a silence created this minute right away The page floors its clock to the minute; the badge now reads the unfloored clock, so a silence created from the incident shows as soon as the list refreshes. --- .../alerts/incident/incident.scene.test.ts | 42 ++++++++++++++++++- apps/web/src/page/alerts/incident/model.ts | 2 + apps/web/src/page/alerts/incident/update.ts | 5 ++- apps/web/src/page/alerts/incident/view.ts | 6 ++- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/apps/web/src/page/alerts/incident/incident.scene.test.ts b/apps/web/src/page/alerts/incident/incident.scene.test.ts index 582d3b7913..ea37fad442 100644 --- a/apps/web/src/page/alerts/incident/incident.scene.test.ts +++ b/apps/web/src/page/alerts/incident/incident.scene.test.ts @@ -1,6 +1,6 @@ import { OrgId } from "@maple/domain" import * as Policy from "@maple/domain/policy" -import { AlertRulePreviewResponse } from "@maple/domain/http" +import { AlertRuleId, AlertRulePreviewResponse, AlertSilenceId } from "@maple/domain/http" import { Result, Schema, Array as Arr } from "effect" import { click, @@ -343,6 +343,46 @@ describe("alert incident, silences", () => { }) }) + test("shows the badge for a silence that started earlier in the current minute", () => { + // The page floors its clock to the minute; a silence created just now starts after that floor. + const clock = Math.floor(NOW / 60_000) * 60_000 + 40_000 + const model = Arr.reduce( + [ + Message.ReadClock({ nowMs: clock }), + Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow()] }), + Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(2)] }), + ], + init(route(), context).model, + (current, message) => step(current, message).model, + ) + const silence = { + id: Schema.decodeSync(AlertSilenceId)("22222222-2222-4222-8222-222222222222"), + object: "alert_silence" as const, + reason: "Deploy", + matchers: { rule_ids: [Schema.decodeSync(AlertRuleId)(ruleRow(2).id)] }, + status: "active" as const, + starts_at: new Date(clock - 10_000).toISOString(), + ends_at: new Date(clock + 60 * 60_000).toISOString(), + created_by: "user_1", + created_at: new Date(clock).toISOString(), + updated_at: new Date(clock).toISOString(), + } + scene( + { update: pageUpdate, view }, + given( + step( + model, + Message.CompletedFetchSilences({ + key: model.silences.key ?? "", + result: Result.succeed([silence]), + }), + ).model, + ), + themeResolved(), + expectView(text(/^Silenced until /u)).toExist(), + ) + }) + test("offers Silence to managers once the rule is known, and never to members", () => { scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/alerts/incident/model.ts b/apps/web/src/page/alerts/incident/model.ts index 5bd183fe1f..5ad0b4deac 100644 --- a/apps/web/src/page/alerts/incident/model.ts +++ b/apps/web/src/page/alerts/incident/model.ts @@ -42,5 +42,7 @@ export const Model = Schema.Struct({ silenceDialog: SilenceDialog.Model, /** What the session may do (`Policy.grantsOf`); none until it answers, so Silence stays hidden. */ grants: Schema.Array(Policy.PermissionName), + /** The clock as read, not floored: a silence created this minute is already in force. */ + clockMs: Schema.NullOr(Schema.Number), }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/alerts/incident/update.ts b/apps/web/src/page/alerts/incident/update.ts index e1c33412a3..346399136d 100644 --- a/apps/web/src/page/alerts/incident/update.ts +++ b/apps/web/src/page/alerts/incident/update.ts @@ -103,7 +103,7 @@ const openSilence = (model: Model, context: PageContext): Return => model.silenceDialog, target, context.timeZone, - model.nowMs ?? 0, + model.clockMs ?? 0, ), ) }, @@ -128,6 +128,7 @@ export const init = (route: IncidentRoute, context: PageContext): Return => { silences: Keyed.idle(), silenceDialog: SilenceDialog.init("alert-incident-silence"), grants: [], + clockMs: null, }, commands: [ ReadIncidentClock(), @@ -317,7 +318,7 @@ const withIncidents = ( export const update = (model: Model, message: Message, context: PageContext): Return => Message.match(message, { - ReadClock: ({ nowMs }) => followData({ ...model, nowMs: minuteOf(nowMs) }, context), + ReadClock: ({ nowMs }) => followData({ ...model, nowMs: minuteOf(nowMs), clockMs: nowMs }, context), UpdatedIncidents: ({ orgId, rows }) => withIncidents(model, context, orgId, rows), UpdatedRules: ({ orgId, rows }) => withRuleRows(model, context, orgId, { rules: rows }), UpdatedRuleStates: ({ orgId, rows }) => withRuleRows(model, context, orgId, { states: rows }), diff --git a/apps/web/src/page/alerts/incident/view.ts b/apps/web/src/page/alerts/incident/view.ts index 360756b97d..054beeccbf 100644 --- a/apps/web/src/page/alerts/incident/view.ts +++ b/apps/web/src/page/alerts/incident/view.ts @@ -133,8 +133,10 @@ const isOpenIncident = (model: Model): boolean => }) /** "Silenced until 15:00" over an open incident, or a note when the silences could not be read. */ -const silenceBadges = (h: H, model: Model, view: IncidentView, now: number, timeZone: string) => { +const silenceBadges = (h: H, model: Model, view: IncidentView, timeZone: string) => { if (view.incident.status !== "open") return [] + // The unfloored clock: a silence created this minute is in force already. + const now = model.clockMs ?? model.nowMs ?? 0 if (AsyncData.isFailure(model.silences.data)) return [silenceCheckFailed(h)] return Option.toArray( Option.map( @@ -349,7 +351,7 @@ export const view = Submodel.defineView((model, view.rule, now, timeZone, - silenceBadges(h, model, view, now, timeZone), + silenceBadges(h, model, view, timeZone), ), actions: headerAction( h, From 83ec5939c237e754cf487ffcb6e6a79f886681d5 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 12:07:22 +0100 Subject: [PATCH 16/28] fix(web): silences land in the right section at once; preview test fixes Found by the preview test on #2292: - The hub places rows by its clock; it now reads the clock again when the list lands, so a created, ended or cancelled silence moves at once instead of at the next 30-second tick. - A cancelled silence reads "Cancelled", not "Ended"; "Nothing is silenced right now" sits above the lists; a rule-scoped silence shows its rule count until rule names sync; a long reason has a tooltip. - The dialog's duration picker scrolls instead of overflowing on a phone ("Pick time" for the custom end), and its scope hint is plainer. --- .../alerts/hub/hub-silences.scene.test.ts | 32 ++++++++++++++++++- apps/web/src/page/alerts/hub/update.ts | 3 ++ apps/web/src/page/alerts/hub/view-silences.ts | 21 +++++++++--- .../src/page/alerts/silence-dialog/view.ts | 5 +-- 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts index c5e20a8288..886703f853 100644 --- a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts @@ -1,6 +1,6 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" -import { AlertSilenceId } from "@maple/domain/http" +import { AlertRuleId, AlertSilenceId } from "@maple/domain/http" import type { V2AlertSilence } from "@maple/domain/http/v2" import { Result, Schema, Array as Arr } from "effect" import * as Animation from "@foldkit/ui/animation" @@ -30,6 +30,7 @@ const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } const pageUpdate = (model: Model, message: Message) => update(model, message, context) const view = withViewInputs(pageView, { context })() const asSilenceId = Schema.decodeSync(AlertSilenceId) +const asRuleId = Schema.decodeSync(AlertRuleId) const iso = (ms: number) => new Date(ms).toISOString() const silence = ( @@ -140,6 +141,35 @@ describe("alerts hub, Silences tab", () => { }) }) + test("reads the clock again when the list lands, so a new silence is placed by it", () => { + const entered = init({ _tag: "Alerts", search: { tab: "silences" } }, context).model + const step = pageUpdate( + entered, + Message.CompletedFetchSilences({ key: entered.silences.key ?? "", result: Result.succeed([]) }), + ) + expect(Arr.map(step.commands ?? [], ({ name }) => name)).toContain("ReadAlertsHubClock") + }) + + test("names a cancelled silence as cancelled, and a rule scope before rules sync", () => { + scene( + { update: pageUpdate, view }, + given( + onSilences(ENGINEER, [ + silence({ startMin: -5, endMin: -5, status: "ended" }), + silence({ + id: asSilenceId("44444444-4444-4444-8444-444444444444"), + reason: "Rule only", + matchers: { rule_ids: [asRuleId("11111111-1111-4111-8111-111111111111")] }, + startMin: -10, + endMin: 50, + }), + ]), + ), + expectView(text(/^Cancelled /u)).toExist(), + expectView(text(/· 1 rule$/u)).toExist(), + ) + }) + test("New silence opens the dialog with typed scope fields", () => { scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 43c294f0f5..ad7e70b3e8 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -606,8 +606,11 @@ export const update = (model: Model, message: Message, context: PageContext): Re }), }) }, + // Rows are placed by the clock: read it again once the list lands, so a silence created or + // ended a moment ago is in its section now, not at the next 30-second tick. CompletedFetchSilences: ({ key, result }) => ({ model: { ...model, silences: Keyed.accept(model.silences, key, result) }, + commands: [ReadHubClock()], }), ClickedRetrySilences: () => refreshSilences(model, context), ClickedNewSilence: () => diff --git a/apps/web/src/page/alerts/hub/view-silences.ts b/apps/web/src/page/alerts/hub/view-silences.ts index c545a87573..b28020f776 100644 --- a/apps/web/src/page/alerts/hub/view-silences.ts +++ b/apps/web/src/page/alerts/hub/view-silences.ts @@ -49,11 +49,14 @@ const windowText = (silence: V2AlertSilence, nowMs: number, timeZone: string): s const status = silenceStatusAt(silence, nowMs) const start = formatSilenceTime(startsAtMs(silence), nowMs, timeZone) const end = formatSilenceTime(endsAtMs(silence), nowMs, timeZone) + // A cancelled silence comes back with its window collapsed to one instant: it never ran. return status === "active" ? `Until ${end}` : status === "scheduled" ? `${start} to ${end}` - : `Ended ${end}` + : startsAtMs(silence) === endsAtMs(silence) + ? `Cancelled ${end}` + : `Ended ${end}` } const silenceRow = ( @@ -72,7 +75,7 @@ const silenceRow = ( return listRow(h, { divided: true, leading: icon(h, "BellSlashIcon", { size: 16 }), - title: silence.reason, + title: h.span([h.Title(silence.reason)], [silence.reason]), meta: Arr.join( [ windowText(silence, options.nowMs, options.timeZone), @@ -81,6 +84,14 @@ const silenceRow = ( options.rulesLoaded ? silence.matchers : { ...silence.matchers, rule_ids: [] }, options.ruleName, ), + // Until rules sync, a rule-scoped silence still says it covers rules. + ...(options.rulesLoaded || (silence.matchers.rule_ids ?? []).length === 0 + ? [] + : [ + (silence.matchers.rule_ids ?? []).length === 1 + ? "1 rule" + : `${(silence.matchers.rule_ids ?? []).length} rules`, + ]), ], " · ", ), @@ -153,12 +164,12 @@ const loaded = ( if (active.length + scheduled.length + ended.length === 0) return silencesEmptyState(h) const options = { nowMs, timeZone, ruleName, rulesLoaded: model.rules !== null } return vstack(h, { gap: "6" }, [ - ...section(h, model, "Active", active, options), - ...section(h, model, "Scheduled", scheduled, options), - ...section(h, model, "Ended in the last 7 days", ended, options), ...(active.length === 0 ? [typography(h, { size: "xs", tone: "muted" }, ["Nothing is silenced right now"])] : []), + ...section(h, model, "Active", active, options), + ...section(h, model, "Scheduled", scheduled, options), + ...section(h, model, "Ended in the last 7 days", ended, options), ]) } diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index 339a1acdf8..1f04509d34 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -27,7 +27,7 @@ const DURATIONS: ReadonlyArray<{ value: SilenceDuration; label: string }> = [ { value: "1h", label: "1 hour" }, { value: "4h", label: "4 hours" }, { value: "morning", label: "Until 09:00" }, - { value: "custom", label: "Until…" }, + { value: "custom", label: "Pick time" }, ] /** "Silence for 1 hour", or the end it will stop at: "Silence until Nov 7, 18:30". */ @@ -124,7 +124,7 @@ const scopeSection = (h: H, model: Model): Html => }), ), typography(h, { size: "xs", tone: "muted" }, [ - "Fill in at least one. A rule is silenced when all of its services and environments are listed and it carries one of the tags.", + "Fill in at least one. Every listed field must match: a rule's services and environments all listed, and one of its tags.", ]), ]), ]) @@ -138,6 +138,7 @@ const durationSection = (h: H, model: Model): Html => value: model.duration, onChange: (duration) => Message.ChangedDuration({ duration }), size: "sm", + className: "max-w-full overflow-x-auto", }), ...(model.duration === "custom" ? [ From d8401e1afb3e0eb805d1e4e9c59b42fa4c0afe28 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 17:55:41 +0100 Subject: [PATCH 17/28] test(web): add the silence fields to main's v2 incident fixtures --- apps/web/src/page/alerts/hub/hub.scene.test.ts | 2 ++ apps/web/src/page/alerts/incident/incident.scene.test.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/apps/web/src/page/alerts/hub/hub.scene.test.ts b/apps/web/src/page/alerts/hub/hub.scene.test.ts index 988febcb10..842cc5275d 100644 --- a/apps/web/src/page/alerts/hub/hub.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub.scene.test.ts @@ -123,6 +123,8 @@ describe("alerts hub", () => { acknowledged_by: null, acknowledged_via: null, error_issue_id: null, + silence_id: null, + silenced_at: null, }) test("the inbox acknowledges a firing incident and moves it out of the firing count", () => { diff --git a/apps/web/src/page/alerts/incident/incident.scene.test.ts b/apps/web/src/page/alerts/incident/incident.scene.test.ts index e1c64065d0..57d5cf8157 100644 --- a/apps/web/src/page/alerts/incident/incident.scene.test.ts +++ b/apps/web/src/page/alerts/incident/incident.scene.test.ts @@ -492,6 +492,8 @@ describe("alert incident", () => { acknowledged_by: acknowledgedAt === null ? null : { user_id: "user_dana", name: "Dana Kim" }, acknowledged_via: acknowledgedAt === null ? null : "web", error_issue_id: null, + silence_id: null, + silenced_at: null, }) const ackEvent = (type: "acknowledged" | "unacknowledged", at: string) => From 083cbf2664d4dd0b3f1b3271d2499c48faef9d2c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 18:02:59 +0100 Subject: [PATCH 18/28] fix(alerts): no catch-up page for an incident acknowledged during its silence An acknowledged incident gets no reminder, so the page a silence held back is not sent when the silence ends either. silenced_at stays, so an escalation that clears the ack still sends it. --- .../alerts/AlertsService.silences.test.ts | 40 ++++++++++++++++++- .../src/services/alerts/AlertsService.ts | 4 +- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/services/alerts/AlertsService.silences.test.ts b/packages/backend/src/services/alerts/AlertsService.silences.test.ts index a7c86541f4..57c012b828 100644 --- a/packages/backend/src/services/alerts/AlertsService.silences.test.ts +++ b/packages/backend/src/services/alerts/AlertsService.silences.test.ts @@ -1,5 +1,5 @@ import { afterEach, assert, describe, it } from "@effect/vitest" -import { Clock, Duration, Effect, Exit, Array as Arr } from "effect" +import { Clock, Duration, Effect, Exit, Layer, Array as Arr } from "effect" import { TestClock } from "effect/testing" import { AlertForbiddenError, @@ -14,6 +14,7 @@ import { Schema } from "effect" import { cleanupTestDbs, createTestDb, type TestDb } from "@maple/backend/platform/test-pglite" import { AlertsService, type AlertsServiceApi } from "./AlertsService" import { AlertSilencesService } from "./AlertSilencesService" +import { AlertIncidentAckService } from "./AlertIncidentAckService" import { DEFAULT_CLOCK_EPOCH_MS, adminRoles, @@ -323,6 +324,43 @@ describe("alert silences, pages in flight and reminders", () => { }) }) +describe("alert silences and acknowledgement", () => { + it.effect("does not page an incident acknowledged during the silence once it ends", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRateRows(10) as ReadonlyArray> } + const sent: Array = [] + const fetchImpl = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sent.push(new Headers(init?.headers).get("x-maple-event-type") ?? "") + return new Response("ok", { status: 200 }) + }) as typeof fetch + const layer = Layer.merge( + makeLayer(testDb, makeWarehouseStub(state), { fetch: fetchImpl }), + AlertIncidentAckService.layer.pipe(Layer.provide(testDb.layer)), + ) + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const alerts = yield* AlertsService + const silences = yield* AlertSilencesService + const acks = yield* AlertIncidentAckService + yield* createRule(alerts) + const created = yield* silence({ services: ["checkout"] }) + yield* ticks(alerts, 2) + const incident = (yield* alerts.listIncidents(orgId)).incidents[0] + assert.isDefined(incident) + if (incident === undefined) return + yield* acks.acknowledge(orgId, incident.id, { userId, name: "Dana", source: "web" }) + + yield* silences.end(orgId, userId, adminRoles, created.id) + yield* ticks(alerts, 3) + + assert.deepStrictEqual(yield* deliveries(alerts), ["trigger:silenced"]) + assert.deepStrictEqual(sent, []) + // Still owed: an escalation that clears the ack sends it then. + assert.isNotNull((yield* alerts.listIncidents(orgId)).incidents[0]?.silencedAt) + }).pipe(Effect.provide(layer)) + }) +}) + describe("AlertSilencesService", () => { it.effect("validates the window, the rule ids and the caller's role", () => { const { layer } = setup() diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index 7292f2ca14..955c8f1e43 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -3370,7 +3370,9 @@ export class AlertsService extends Context.Service Date: Fri, 6 Nov 2026 18:08:22 +0100 Subject: [PATCH 19/28] fix(ai): create_alert_silence keeps the validation details (which rule ids do not exist) --- apps/ai/src/mcp/lib/alert-silences.ts | 13 ++++++++++- .../__tests__/alert-silence-tools.test.ts | 22 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/ai/src/mcp/lib/alert-silences.ts b/apps/ai/src/mcp/lib/alert-silences.ts index 72174139f1..06a567cdd9 100644 --- a/apps/ai/src/mcp/lib/alert-silences.ts +++ b/apps/ai/src/mcp/lib/alert-silences.ts @@ -71,8 +71,19 @@ const failWith = (pipeName: string) => (error: AlertForbiddenError | AlertPersis /** 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: `Invalid alert silence: ${error.message}` })), + 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"), } diff --git a/apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts index 89722b1df3..12329ae3a8 100644 --- a/apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts @@ -9,6 +9,7 @@ import { AlertSilenceCreateRequest, AlertSilenceDocument, AlertSilenceNotFoundError, + AlertValidationError, AlertSilencesListResponse, OrgId, UserId, @@ -50,6 +51,8 @@ interface Seen { created?: AlertSilenceCreateRequest listedStatuses: Array ended?: string + /** The service refuses the create with this, for one test. */ + createFails?: AlertValidationError /** More silences beside `stored`, for one test. */ extra?: ReadonlyArray } @@ -83,6 +86,7 @@ const layer = (seen: Seen) => ) }, create: (_org: unknown, _user: unknown, _roles: unknown, request: AlertSilenceCreateRequest) => { + if (seen.createFails !== undefined) return Effect.fail(seen.createFails) seen.created = request return Effect.succeed( Schema.decodeSync(AlertSilenceDocument)({ @@ -208,6 +212,24 @@ describe("alert silence tools", () => { expect(seen.created?.endsAt).toBe("2030-01-01T00:30:00.000Z") }) + it("create_alert_silence names the rule ids the service could not find", async () => { + const seen: Seen = { + ...fresh(), + createFails: new AlertValidationError({ + message: "Unknown alert rule in silence matchers", + details: [`Alert rule ${RULE_ID} does not exist`], + }), + } + const result = await run( + "create_alert_silence", + { reason: "x", rule_ids: [RULE_ID], duration_minutes: 30 }, + seen, + ) + expect(result._tag === "Failure" ? result.failure.message : "").toContain( + `Alert rule ${RULE_ID} does not exist`, + ) + }) + it("create_alert_silence refuses a silence with no matcher", async () => { const seen = fresh() const result = await run( From 4a135b6245b4fff06f06dbee2f71265faed6e472 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 18:11:53 +0100 Subject: [PATCH 20/28] fix(alerts): claim the catch-up page in SQL against a late ack; an escalation settles it - The catch-up is committed by a conditional update (acknowledged_at is null and a page is still owed), so an ack that lands after the tick's prefetch keeps it owed and unsent. - Any other page sent while one is owed (an escalation, which pages through an ack) clears silenced_at, so the next check does not send a second one. --- .../alerts/AlertsService.silences.test.ts | 73 +++++++++++++++++++ .../src/services/alerts/AlertsService.ts | 48 +++++++++--- 2 files changed, 112 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/services/alerts/AlertsService.silences.test.ts b/packages/backend/src/services/alerts/AlertsService.silences.test.ts index 57c012b828..a485660145 100644 --- a/packages/backend/src/services/alerts/AlertsService.silences.test.ts +++ b/packages/backend/src/services/alerts/AlertsService.silences.test.ts @@ -4,6 +4,7 @@ import { TestClock } from "effect/testing" import { AlertForbiddenError, AlertRuleId, + AlertRuleUpsertRequest, AlertValidationError, AlertSilenceCreateRequest, AlertSilenceUpdateRequest, @@ -361,6 +362,78 @@ describe("alert silences and acknowledgement", () => { }) }) +describe("alert silences, acknowledgement and escalation", () => { + it.effect("an escalation of an acknowledged incident pages once and settles the held-back page", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRateRows(10) as ReadonlyArray> } + const sent: Array = [] + const fetchImpl = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sent.push(new Headers(init?.headers).get("x-maple-event-type") ?? "") + return new Response("ok", { status: 200 }) + }) as typeof fetch + const layer = Layer.merge( + makeLayer(testDb, makeWarehouseStub(state), { fetch: fetchImpl }), + AlertIncidentAckService.layer.pipe(Layer.provide(testDb.layer)), + ) + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const alerts = yield* AlertsService + const silences = yield* AlertSilencesService + const acks = yield* AlertIncidentAckService + const destination = yield* createWebhookDestination(alerts, orgId, userId) + yield* alerts.createRule( + orgId, + userId, + adminRoles, + new AlertRuleUpsertRequest({ + name: "Checkout error rate tiers", + enabled: true, + serviceNames: ["checkout"], + signalType: "error_rate", + comparator: "gt", + thresholds: [ + { + severity: "critical", + value: 20, + upper: null, + recoveryValue: null, + destinationIds: [destination.id], + }, + { + severity: "warning", + value: 5, + upper: null, + recoveryValue: null, + destinationIds: [destination.id], + }, + ], + windowMinutes: 5, + minimumSampleCount: 10, + consecutiveBreachesRequired: 2, + consecutiveHealthyRequired: 2, + renotifyIntervalMinutes: 30, + }), + ) + const created = yield* silence({ services: ["checkout"] }) + yield* ticks(alerts, 2) + const incident = (yield* alerts.listIncidents(orgId)).incidents[0] + assert.isDefined(incident) + if (incident === undefined) return + yield* acks.acknowledge(orgId, incident.id, { userId, name: "Dana", source: "web" }) + yield* silences.end(orgId, userId, adminRoles, created.id) + yield* ticks(alerts, 2) + assert.deepStrictEqual(sent, []) + + // Worse now: the escalation pages despite the ack, and that page settles the held one. + state.tracesAggregateRows = errorRateRows(30) + yield* ticks(alerts, 5) + + assert.deepStrictEqual(sent, ["trigger"]) + assert.isNull((yield* alerts.listIncidents(orgId)).incidents[0]?.silencedAt) + }).pipe(Effect.provide(layer)) + }) +}) + describe("AlertSilencesService", () => { it.effect("validates the window, the rule ids and the caller's role", () => { const { layer } = setup() diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index 955c8f1e43..8059420299 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -3435,13 +3435,16 @@ export class AlertsService extends Context.Service [$.id.eq(openIncident.id), $.status.eq("open")]) .returning("id"), ) - if (updated.length > 0 && catchUpDeliveries.length > 0) + // The catch-up is claimed in SQL: an ack that committed after the prefetch keeps + // the page owed and unsent, rather than racing it out. + const caughtUp = + updated.length > 0 && catchUp + ? yield* db.run( + PG.update(AlertIncidents) + .set({ silencedAt: null, lastNotifiedAt: timestamp }) + .where(($) => [ + $.id.eq(openIncident.id), + $.status.eq("open"), + $.acknowledgedAt.isNull(), + $.silencedAt.isNotNull(), + ]) + .returning("id"), + ) + : [] + if (caughtUp.length > 0 && catchUpDeliveries.length > 0) yield* insertIncidentDeliveries(db, catchUpDeliveries) + const result = { updated, isCatchUpSent: caughtUp.length > 0 } if (updated.length === 0 || lifecycle.transition !== "escalated") - return updated + return result const cleared = yield* db.run( PG.update(AlertIncidents) .set(clearedAcknowledgement) @@ -3483,10 +3503,10 @@ export class AlertsService extends Context.Service Date: Fri, 6 Nov 2026 18:16:01 +0100 Subject: [PATCH 21/28] fix(ai): log a failed silence reason lookup in get_incident_timeline --- apps/ai/src/mcp/tools/get-incident-timeline.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/ai/src/mcp/tools/get-incident-timeline.ts b/apps/ai/src/mcp/tools/get-incident-timeline.ts index 60b457f6d9..0a067647d5 100644 --- a/apps/ai/src/mcp/tools/get-incident-timeline.ts +++ b/apps/ai/src/mcp/tools/get-incident-timeline.ts @@ -37,6 +37,12 @@ const silenceReasons = Effect.fn("McpTool.getIncidentTimeline.silenceReasons")(f (id) => silences.get(orgId, id).pipe( Effect.map((silence) => [[silence.id, silence.reason] as const]), + // The line falls back to the bare id, but a failed lookup is still recorded. + Effect.tapError((error) => + Effect.logWarning("Alert silence reason lookup failed").pipe( + Effect.annotateLogs({ silenceId: id, errorTag: error._tag }), + ), + ), Effect.orElseSucceed(() => []), ), { concurrency: 4 }, From ea50321efb8b435ca57562519ac395235dc2fbdd Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 18:21:01 +0100 Subject: [PATCH 22/28] fix(alerts): a catch-up's notification anchor moves only when the catch-up is claimed On a catch-up tick the refresh no longer advances lastNotifiedAt; the conditional catch-up write does, so a late ack that keeps the page unsent leaves the anchor alone. --- packages/backend/src/services/alerts/AlertsService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index 8059420299..51db550d1b 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -3438,7 +3438,9 @@ export class AlertsService extends Context.Service Date: Fri, 6 Nov 2026 18:24:10 +0100 Subject: [PATCH 23/28] fix(ai): the timeline reads a settled silence hold as past, not as the last notification --- apps/ai/src/mcp/tools/get-incident-timeline.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ai/src/mcp/tools/get-incident-timeline.ts b/apps/ai/src/mcp/tools/get-incident-timeline.ts index 0a067647d5..6c8c329c73 100644 --- a/apps/ai/src/mcp/tools/get-incident-timeline.ts +++ b/apps/ai/src/mcp/tools/get-incident-timeline.ts @@ -60,7 +60,7 @@ function silenceLine(inc: { const named = inc.silenceReason === undefined ? inc.silenceId : `"${inc.silenceReason}" (${inc.silenceId})` return inc.silencedAt === undefined || inc.silencedAt === null - ? `last notification was held back by silence ${named}` + ? `a page was held back earlier by silence ${named}; nothing is owed now` : `held back by silence ${named} since ${formatTimestamp(inc.silencedAt)}` } From e9f0de8af65e37bc1b95b3f055eeb81653fe7463 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 18:27:20 +0100 Subject: [PATCH 24/28] fix(alerts): a phone paged before a silence still gets the all-clear during it lastDeliveredEventType only tracks destination deliveries, so a phone-only rule never set it. The resolve push is now skipped only when the page is still owed and nothing was ever delivered. --- packages/backend/src/services/alerts/AlertsService.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index 51db550d1b..82d9a703b8 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -1586,8 +1586,13 @@ export class AlertsService extends Context.Service Date: Fri, 6 Nov 2026 18:44:01 +0100 Subject: [PATCH 25/28] fix(alerts): withhold the phone all-clear only for an incident opened under a silence An incident's owed mark equals its first trigger only when it opened under a silence, the one case its trigger was never pushed; a reminder held later leaves a phone that was paged, so it gets the all-clear. The silenced metric and log fire only when something was held. --- .../src/services/alerts/AlertsService.ts | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index 82d9a703b8..9e1f3bd190 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -1577,22 +1577,24 @@ export class AlertsService extends Context.Service 0) yield* dbExecute((db) => insertIncidentDeliveries(db, deliveries)) if (silenceId !== null) { - yield* Metric.update(AlertingMetrics.notificationsSilencedTotal, 1) - yield* Effect.logInfo("Alert notification held back by a silence").pipe( - Effect.annotateLogs({ - ruleId: rule.id, - incidentId: incident.id, - silenceId, - eventType, - }), - ) - // The phone hears the all-clear of a page it already got: only a page still owed with - // nothing ever delivered was never pushed (a phone-only rule delivers nothing). - if ( + // The phone hears the all-clear of a page it already got. Only an incident that + // opened under a silence (its owed mark set when it opened) never pushed one. + const isPushHeld = eventType !== "resolve" || - (incident.silencedAt !== null && incident.lastDeliveredEventType === null) - ) - return + (incident.silencedAt !== null && + incident.silencedAt === incident.firstTriggeredAt) + if (isPushHeld || Arr.some(deliveries, (delivery) => delivery.silenceId !== null)) { + yield* Metric.update(AlertingMetrics.notificationsSilencedTotal, 1) + yield* Effect.logInfo("Alert notification held back by a silence").pipe( + Effect.annotateLogs({ + ruleId: rule.id, + incidentId: incident.id, + silenceId, + eventType, + }), + ) + } + if (isPushHeld) return } yield* pushIncidentNotification( orgId, From af5bc38e120d6ea470536698065255ff7045b461 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 20:06:52 +0100 Subject: [PATCH 26/28] test(web): cover the silence dialog's save result Co-Authored-By: Claude Opus 5.5 --- .../page/alerts/silence-dialog/dialog.test.ts | 75 ++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts index bedc57bb70..4c0efb9730 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts @@ -1,6 +1,18 @@ import { describe, expect, it } from "vitest" -import { Option } from "effect" -import { init, isFormReady, matchersOf, silenceEndMs, untilError } from "./dialog" +import { AlertSilenceId } from "@maple/domain/http" +import type { V2AlertSilence } from "@maple/domain/http/v2" +import { Option, Result, Schema } from "effect" +import { + init, + isFormReady, + matchersOf, + Message, + openNew, + OutMessage, + silenceEndMs, + untilError, + update, +} from "./dialog" // 2026-11-06 14:20 in Berlin (UTC+1). const NOW = Date.UTC(2026, 10, 6, 13, 20) @@ -77,3 +89,62 @@ describe("isFormReady", () => { ) }) }) + +describe("saving", () => { + const SILENCE: V2AlertSilence = { + id: Schema.decodeSync(AlertSilenceId)("22222222-2222-4222-8222-222222222222"), + object: "alert_silence", + reason: "deploy", + matchers: { services: ["checkout"] }, + status: "active", + starts_at: new Date(NOW).toISOString(), + ends_at: new Date(NOW + 60 * 60_000).toISOString(), + created_by: "user_1", + created_at: new Date(NOW).toISOString(), + updated_at: new Date(NOW).toISOString(), + } + const saving = () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const typed = { ...opened, reason: "deploy", scope: { ...opened.scope, services: "checkout" } } + return update(typed, Message.ClickedSave()).model + } + + it("closes the form and reports the new silence", () => { + const model = saving() + expect(model.isSaving).toBe(true) + const saved = update( + model, + Message.CompletedSave({ openCount: model.openCount, result: Result.succeed(SILENCE) }), + ) + expect(saved.model.isSaving).toBe(false) + expect(saved.model.dialog.isOpen).toBe(false) + expect(saved.outMessage).toEqual(OutMessage.CreatedSilence({ silence: SILENCE })) + }) + + it("keeps a reopened form open when an older save lands", () => { + const model = saving() + const saved = update( + model, + Message.CompletedSave({ openCount: model.openCount - 1, result: Result.succeed(SILENCE) }), + ) + expect(saved.model.dialog.isOpen).toBe(true) + expect(saved.outMessage).toEqual(OutMessage.CreatedSilence({ silence: SILENCE })) + }) + + it("keeps the form open and toasts the failure", () => { + const model = saving() + const failed = update( + model, + Message.CompletedSave({ openCount: model.openCount, result: Result.fail("nope") }), + ) + expect(failed.model.isSaving).toBe(false) + expect(failed.model.dialog.isOpen).toBe(true) + expect(failed.outMessage).toEqual( + OutMessage.RequestedToast({ + type: "error", + title: "Couldn't silence alerts", + description: "nope", + }), + ) + }) +}) From 86e0151f73dcc63116f8fd2d0eaa5c6d27fca48c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 20:19:04 +0100 Subject: [PATCH 27/28] fix(web): tick the incident clock, keep a reopened silence form saving, label held reminders Co-Authored-By: Claude Opus 5.5 --- apps/web/src/page/alerts/incident/index.ts | 28 +++++++++++++++++-- apps/web/src/page/alerts/incident/message.ts | 2 ++ apps/web/src/page/alerts/incident/update.ts | 1 + .../page/alerts/silence-dialog/dialog.test.ts | 3 +- .../src/page/alerts/silence-dialog/dialog.ts | 8 +++--- .../src/page/alerts/silence-dialog/view.ts | 28 ++++++++++--------- .../src/alerts/incident-timeline.test.ts | 4 +++ .../web-data/src/alerts/incident-timeline.ts | 4 ++- 8 files changed, 56 insertions(+), 22 deletions(-) diff --git a/apps/web/src/page/alerts/incident/index.ts b/apps/web/src/page/alerts/incident/index.ts index 0413e147d6..49249d4b5a 100644 --- a/apps/web/src/page/alerts/incident/index.ts +++ b/apps/web/src/page/alerts/incident/index.ts @@ -1,8 +1,10 @@ -import { Schema } from "effect" +import { Clock, Duration, Effect, Option, Schema, Stream } from "effect" import { Subscription } from "foldkit" +import { pollTicks } from "../../../data/poll" import type { Resources } from "../../../platform/resources" import { definePage, type PageSubscriptionInput } from "../../contract" import { incidentRows, ruleRows, ruleStateRows } from "../shared/collections" +import { incidentViewOf } from "./derive" import { Message } from "./message" import { Model } from "./model" import { init, update } from "./update" @@ -13,7 +15,8 @@ const byOrg = { orgId: Schema.String } const subscriptions = Subscription.make, Message, Resources>()((entry) => ({ incidents: entry(byOrg, { modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => incidentRows(orgId, (rows) => Message.UpdatedIncidents({ orgId, rows })), + dependenciesToStream: ({ orgId }) => + incidentRows(orgId, (rows) => Message.UpdatedIncidents({ orgId, rows })), }), rules: entry(byOrg, { modelToDependencies: ({ context }) => ({ orgId: context.orgId }), @@ -21,8 +24,27 @@ const subscriptions = Subscription.make, Message, R }), states: entry(byOrg, { modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => ruleStateRows(orgId, (rows) => Message.UpdatedRuleStates({ orgId, rows })), + dependenciesToStream: ({ orgId }) => + ruleStateRows(orgId, (rows) => Message.UpdatedRuleStates({ orgId, rows })), }), + clock: entry( + { isOpen: Schema.Boolean }, + { + modelToDependencies: ({ model }) => ({ + isOpen: Option.exists(incidentViewOf(model), ({ incident }) => incident.status === "open"), + }), + dependenciesToStream: ({ isOpen }) => + isOpen + ? pollTicks(Duration.minutes(1)).pipe( + Stream.mapEffect(() => + Effect.map(Clock.currentTimeMillis, (nowMs) => + Message.TickedClock({ nowMs }), + ), + ), + ) + : Stream.empty, + }, + ), })) /** `/alerts/incidents/$incidentId`: one instance per incident. */ diff --git a/apps/web/src/page/alerts/incident/message.ts b/apps/web/src/page/alerts/incident/message.ts index 1025d996e0..26c47d9bb7 100644 --- a/apps/web/src/page/alerts/incident/message.ts +++ b/apps/web/src/page/alerts/incident/message.ts @@ -26,6 +26,8 @@ const rows = (row: S) => ({ orgId: Schema.String, rows: Sc export const Message = defineMessageUnion({ ReadClock: { nowMs: Schema.Number }, + /** The minute clock while the incident is open, so a silence badge ends on time. */ + TickedClock: { nowMs: Schema.Number }, UpdatedIncidents: rows(AlertIncidentRowSchema), UpdatedRules: rows(AlertRuleRowSchema), UpdatedRuleStates: rows(AlertRuleStateRowSchema), diff --git a/apps/web/src/page/alerts/incident/update.ts b/apps/web/src/page/alerts/incident/update.ts index f87d4bff9b..84407319cb 100644 --- a/apps/web/src/page/alerts/incident/update.ts +++ b/apps/web/src/page/alerts/incident/update.ts @@ -386,6 +386,7 @@ const withIncidents = ( export const update = (model: Model, message: Message, context: PageContext): Return => Message.match(message, { ReadClock: ({ nowMs }) => followData({ ...model, nowMs: minuteOf(nowMs), clockMs: nowMs }, context), + TickedClock: ({ nowMs }) => ({ model: { ...model, clockMs: nowMs } }), UpdatedIncidents: ({ orgId, rows }) => withIncidents(model, context, orgId, rows), UpdatedRules: ({ orgId, rows }) => withRuleRows(model, context, orgId, { rules: rows }), UpdatedRuleStates: ({ orgId, rows }) => withRuleRows(model, context, orgId, { states: rows }), diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts index 4c0efb9730..03f3cb5934 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts @@ -121,13 +121,14 @@ describe("saving", () => { expect(saved.outMessage).toEqual(OutMessage.CreatedSilence({ silence: SILENCE })) }) - it("keeps a reopened form open when an older save lands", () => { + it("leaves a reopened form alone when an older save lands", () => { const model = saving() const saved = update( model, Message.CompletedSave({ openCount: model.openCount - 1, result: Result.succeed(SILENCE) }), ) expect(saved.model.dialog.isOpen).toBe(true) + expect(saved.model.isSaving).toBe(true) expect(saved.outMessage).toEqual(OutMessage.CreatedSilence({ silence: SILENCE })) }) diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.ts b/apps/web/src/page/alerts/silence-dialog/dialog.ts index 504ed4f098..3589f53f5c 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.ts @@ -264,15 +264,15 @@ export const update = (model: Model, message: Message): Return => CompletedSave: ({ openCount, result }) => Result.match(result, { onSuccess: (silence) => { - const settled = { ...model, isSaving: false } + // A result from an earlier open still refreshes the list, but leaves this form alone. const closed = openCount === model.openCount - ? liftDialog(settled, Dialog.close(model.dialog)) - : { model: settled } + ? liftDialog({ ...model, isSaving: false }, Dialog.close(model.dialog)) + : { model } return { ...closed, outMessage: OutMessage.CreatedSilence({ silence }) } }, onFailure: (failure) => ({ - model: { ...model, isSaving: false }, + model: openCount === model.openCount ? { ...model, isSaving: false } : model, outMessage: OutMessage.RequestedToast({ type: "error", title: "Couldn't silence alerts", diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index 1f04509d34..9e748ea70e 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -2,7 +2,7 @@ import { Option, Array as Arr } from "effect" import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { eyebrow } from "@maple/views/ui/eyebrow" -import { fieldDescription, fieldLabel, type FieldIds, input } from "@maple/views/ui/field" +import { fieldDescription, fieldError, fieldLabel, type FieldIds, input } from "@maple/views/ui/field" import { formDialog } from "@maple/views/ui/form-dialog" import type { FieldState } from "@maple/views/ui/input" import { panel } from "@maple/views/ui/panel" @@ -42,8 +42,8 @@ const submitLabel = (model: Model): string => `Silence until ${formatSilenceTime(endMs, model.nowMs, model.timeZone)}`, }) -const stateOf = (value: string): FieldState => ({ - isInvalid: false, +const stateOf = (value: string, isInvalid = false): FieldState => ({ + isInvalid, isDirty: false, isFilled: value !== "", isFocused: false, @@ -67,16 +67,17 @@ const textField = ( onInput: (value: string) => Message type?: string description?: string + error?: Option.Option }>, ): Html => { const fieldIds = ids(options.name, options.description !== undefined) + const error = options.error ?? Option.none() + const state = stateOf(options.value, Option.isSome(error)) return vstack(h, { gap: "1.5" }, [ - fieldLabel(h, { ids: fieldIds, className: "text-xs", state: stateOf(options.value) }, [ - options.label, - ]), + fieldLabel(h, { ids: fieldIds, className: "text-xs", state }, [options.label]), input(h, { ids: fieldIds, - state: stateOf(options.value), + state, attributes: [ h.Value(options.value), h.Placeholder(options.placeholder), @@ -87,7 +88,12 @@ const textField = ( }), ...(options.description === undefined ? [] - : [fieldDescription(h, { ids: fieldIds, state: stateOf(options.value) }, [options.description])]), + : [fieldDescription(h, { ids: fieldIds, state }, [options.description])]), + ...Option.toArray( + Option.map(error, (message) => + fieldError(h, { ids: fieldIds, state, attributes: [h.Role("alert")] }, [message]), + ), + ), ]) } @@ -150,12 +156,8 @@ const durationSection = (h: H, model: Model): Html => type: "datetime-local", onInput: (value) => Message.ChangedUntil({ value }), description: `In ${model.timeZone}. At most 30 days from now.`, + error: untilError(model), }), - ...Option.toArray( - Option.map(untilError(model), (message) => - typography(h, { size: "xs", tone: "destructive", as: "p" }, [message]), - ), - ), ] : []), ]) diff --git a/packages/web-data/src/alerts/incident-timeline.test.ts b/packages/web-data/src/alerts/incident-timeline.test.ts index 2b6636f92f..57fb256b6f 100644 --- a/packages/web-data/src/alerts/incident-timeline.test.ts +++ b/packages/web-data/src/alerts/incident-timeline.test.ts @@ -147,6 +147,10 @@ describe("buildIncidentTimeline", () => { expect(silenced?.title).toBe("Page to #alerts held back by a silence") expect(silenced?.tone).toBe("neutral") expect(silenced?.pending).toBe(false) + const [, reminder] = build(makeIncident(), [ + makeDelivery({ status: "silenced", eventType: "renotify" }), + ]) + expect(reminder?.title).toBe("Reminder to #alerts held back by a silence") }) it("ends a held incident on the hold instead of a breach", () => { diff --git a/packages/web-data/src/alerts/incident-timeline.ts b/packages/web-data/src/alerts/incident-timeline.ts index ff8b5487f6..2c0316d60e 100644 --- a/packages/web-data/src/alerts/incident-timeline.ts +++ b/packages/web-data/src/alerts/incident-timeline.ts @@ -144,7 +144,9 @@ function deliveryEntry( const title = silenced ? event.eventType === "resolve" ? `All-clear to ${name} held back by a silence` - : `Page to ${name} held back by a silence` + : event.eventType === "renotify" + ? `Reminder to ${name} held back by a silence` + : `Page to ${name} held back by a silence` : sending ? `Sending to ${name}` : event.eventType === "resolve" From dc49d67cc255b19fde6856d5e51417187c87ff1b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 20:30:02 +0100 Subject: [PATCH 28/28] fix(web): badges see scheduled silences and the rule page clock ticks Co-Authored-By: Claude Opus 5.5 --- apps/web/src/page/alerts/rule/index.ts | 22 ++++++++++++++++--- apps/web/src/page/alerts/shared/silences.ts | 11 ++++++++-- .../page/alerts/silence-dialog/dialog.test.ts | 9 ++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/apps/web/src/page/alerts/rule/index.ts b/apps/web/src/page/alerts/rule/index.ts index 04e1d0107e..2c10c34d2e 100644 --- a/apps/web/src/page/alerts/rule/index.ts +++ b/apps/web/src/page/alerts/rule/index.ts @@ -1,5 +1,6 @@ -import { Schema } from "effect" +import { Clock, Duration, Effect, Schema, Stream } from "effect" import { Subscription } from "foldkit" +import { pollTicks } from "../../../data/poll" import type { Resources } from "../../../platform/resources" import { definePage, type PageSubscriptionInput } from "../../contract" import { destinationRows, incidentRows, ruleRows, ruleStateRows } from "../shared/collections" @@ -17,17 +18,32 @@ const subscriptions = Subscription.make, Message, R }), incidents: entry(byOrg, { modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => incidentRows(orgId, (rows) => Message.UpdatedIncidents({ orgId, rows })), + dependenciesToStream: ({ orgId }) => + incidentRows(orgId, (rows) => Message.UpdatedIncidents({ orgId, rows })), }), states: entry(byOrg, { modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => ruleStateRows(orgId, (rows) => Message.UpdatedRuleStates({ orgId, rows })), + dependenciesToStream: ({ orgId }) => + ruleStateRows(orgId, (rows) => Message.UpdatedRuleStates({ orgId, rows })), }), destinations: entry(byOrg, { modelToDependencies: ({ context }) => ({ orgId: context.orgId }), dependenciesToStream: ({ orgId }) => destinationRows(orgId, (rows) => Message.UpdatedDestinations({ orgId, rows })), }), + // The minute clock, so a silence badge starts and ends on time while the page stays open. + clock: entry( + {}, + { + modelToDependencies: () => ({}), + dependenciesToStream: () => + pollTicks(Duration.minutes(1)).pipe( + Stream.mapEffect(() => + Effect.map(Clock.currentTimeMillis, (nowMs) => Message.ReadDiagnosisClock({ nowMs })), + ), + ), + }, + ), })) /** `/alerts/$ruleId`: one instance per rule; the tab and window are search. */ diff --git a/apps/web/src/page/alerts/shared/silences.ts b/apps/web/src/page/alerts/shared/silences.ts index 7057d101e9..1501407d08 100644 --- a/apps/web/src/page/alerts/shared/silences.ts +++ b/apps/web/src/page/alerts/shared/silences.ts @@ -40,8 +40,15 @@ export const listSilences = Effect.all( }), ) -/** What a rule or incident page needs for its badge: only the silences in force. */ -export const listActiveSilences = listByStatus("active").pipe(Effect.mapError(toLoadFailure)) +/** What a rule or incident page needs for its badge: those in force, and those that start later. */ +export const listActiveSilences = Effect.all([listByStatus("active"), listByStatus("scheduled")], { + concurrency: "unbounded", +}).pipe( + Effect.mapBoth({ + onSuccess: ([active, scheduled]) => [...active, ...scheduled], + onFailure: toLoadFailure, + }), +) export const createSilence = (payload: V2AlertSilenceCreateParams) => Effect.gen(function* () { diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts index 03f3cb5934..1083a79abf 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts @@ -132,6 +132,15 @@ describe("saving", () => { expect(saved.outMessage).toEqual(OutMessage.CreatedSilence({ silence: SILENCE })) }) + it("leaves a reopened form saving when an older save fails", () => { + const model = saving() + const failed = update( + model, + Message.CompletedSave({ openCount: model.openCount - 1, result: Result.fail("nope") }), + ) + expect(failed.model.isSaving).toBe(true) + }) + it("keeps the form open and toasts the failure", () => { const model = saving() const failed = update(