From 1568e4854b522ab3c3ff1db526752b1bbcfd4828 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 01:07:45 +0100 Subject: [PATCH 01/98] feat(alerts): acknowledge alert incidents (core, API, MCP) An open incident can now be acknowledged ("someone is on it") and unacknowledged. The ack is a sub-state of `open`, like the telemetry hold: four nullable columns on alert_incidents (who, when, name, source), so every open/resolved consumer keeps working. alert_incident_events keeps the history (acknowledged, unacknowledged, ack_cleared) for the timeline and for on-call escalation to read. The lifecycle planner holds reminders and de-escalation notices while an incident is acknowledged. An escalation to a higher severity still pages and clears the ack. Resolve still sends the all-clear and clears it. Surfaces: POST /v2/alerts/incidents/:id/acknowledge and /unacknowledge (alerts:write, 409 on a resolved incident), GET /:id/events, an acknowledged=true|false list filter, and the acknowledge_alert_incident MCP tool (approval-gated in chat like the other mutating tools). --- apps/ai/src/mcp/dispatcher.ts | 2 + apps/ai/src/mcp/lib/alert-rules.ts | 9 + apps/ai/src/mcp/lib/params.ts | 13 +- apps/ai/src/mcp/tool-descriptors.generated.ts | 5 +- .../mcp/tools/__tests__/alert-tools.test.ts | 4 + .../tools/acknowledge-alert-incident.test.ts | 129 + .../mcp/tools/acknowledge-alert-incident.ts | 92 + .../ai/src/mcp/tools/get-incident-timeline.ts | 16 +- apps/ai/src/mcp/tools/list-alert-incidents.ts | 8 +- apps/ai/src/mcp/tools/mutating.ts | 1 + apps/ai/src/mcp/tools/registry.ts | 2 + apps/ai/src/mcp/tools/runtime-requirements.ts | 2 + .../test/mcp/unimplemented-tool-services.ts | 2 + .../api/src/routes/v2/alert-incidents.http.ts | 82 +- apps/api/src/routes/v2/alerts.http.test.ts | 108 + apps/api/src/routes/v2/v2-test-support.ts | 7 +- apps/api/src/runtime/service-graph.ts | 2 + apps/web/src/lab/alerts-fixture.ts | 4 + packages/alerting-core/src/index.ts | 19 +- packages/alerting-core/src/tiers.test.ts | 67 + .../alerts/AlertIncidentAckService.test.ts | 112 + .../alerts/AlertIncidentAckService.ts | 227 + .../services/alerts/AlertReadModelsService.ts | 23 +- .../src/services/alerts/AlertsService.test.ts | 88 + .../src/services/alerts/AlertsService.ts | 38 + .../src/services/org/OrganizationService.ts | 3 + .../migration.sql | 30 + .../snapshot.json | 18980 ++++++++++++++++ packages/db/src/tables/alerts.ts | 42 + packages/db/src/tables/index.ts | 2 + packages/domain/src/http/alerts.ts | 59 + .../domain/src/http/v2/alert-incidents.ts | 129 +- packages/domain/src/http/v2/openapi.test.ts | 3 + packages/domain/src/http/v2/resource-ids.ts | 2 + .../domain/src/http/v2/v2-contract.test.ts | 3 + packages/domain/src/mcp-outputs/alerts.ts | 16 + packages/domain/src/mcp-outputs/catalog.ts | 2 + packages/primitives/src/index.ts | 3 + packages/web-data/src/alerts/headroom.test.ts | 4 + .../src/alerts/incident-timeline.test.ts | 4 + .../src/alerts/overview-derive.test.ts | 4 + packages/web-data/src/alerts/overview.test.ts | 4 + .../src/alerts/preview-insights.test.ts | 4 + packages/web-data/src/collections/alerts.ts | 16 + 44 files changed, 20352 insertions(+), 20 deletions(-) create mode 100644 apps/ai/src/mcp/tools/acknowledge-alert-incident.test.ts create mode 100644 apps/ai/src/mcp/tools/acknowledge-alert-incident.ts create mode 100644 packages/backend/src/services/alerts/AlertIncidentAckService.test.ts create mode 100644 packages/backend/src/services/alerts/AlertIncidentAckService.ts create mode 100644 packages/db/drizzle/20261105234724_alert_incident_ack/migration.sql create mode 100644 packages/db/drizzle/20261105234724_alert_incident_ack/snapshot.json diff --git a/apps/ai/src/mcp/dispatcher.ts b/apps/ai/src/mcp/dispatcher.ts index bdcf068717..9c07575f90 100644 --- a/apps/ai/src/mcp/dispatcher.ts +++ b/apps/ai/src/mcp/dispatcher.ts @@ -10,6 +10,7 @@ import { RepoSandboxService, type RepositoryTarget } from "@maple/backend/servic import { AlertsService } from "@maple/backend/services/alerts/AlertsService" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" +import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" import { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService" @@ -269,6 +270,7 @@ const MAX_PREPARED_REPOSITORIES = 3 const McpRuntimeServicesLive = Layer.mergeAll( AgentFeedbackService.layer, AlertReadModelsService.layer, + AlertIncidentAckService.layer, AlertRulesService.layer, AlertsService.layer, AuditLogService.layer, diff --git a/apps/ai/src/mcp/lib/alert-rules.ts b/apps/ai/src/mcp/lib/alert-rules.ts index e53ed25769..4943da9d11 100644 --- a/apps/ai/src/mcp/lib/alert-rules.ts +++ b/apps/ai/src/mcp/lib/alert-rules.ts @@ -9,6 +9,7 @@ import { AlertRuleId, AlertSeverity, AlertSignalType, + type AlertIncidentDocument, type AlertRuleDocument, type AlertRuleDestinationNotFoundError, type AlertRuleNotFoundError, @@ -286,3 +287,11 @@ export const renderRuleWrite = ( ], } } + +/** Who acknowledged an open incident, for the model; null when nobody has. */ +export const ackLabel = ( + incident: Pick, +): string | null => + incident.acknowledgedAt === null + ? null + : (incident.acknowledgedByName ?? `someone via ${incident.acknowledgedVia ?? "Maple"}`) diff --git a/apps/ai/src/mcp/lib/params.ts b/apps/ai/src/mcp/lib/params.ts index 78b777e545..2ee14fe336 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, + AlertIncidentId, + AlertRuleId, + DashboardId, + ErrorIssueId, +} from "@maple/domain/primitives" import { WarehouseTimeInput, type WarehouseDateTime } from "@maple/query-engine" import { McpInvalidInputError } from "../tools/types" import { resolveWindow } from "./time" @@ -141,6 +147,11 @@ export const optionalList = (description: string) => /** The resources a tool takes ids for, with their REST public-id prefix and internal id schema. */ const RESOURCE_IDS = { alertRule: { prefix: PublicIdPrefixes.alertRule, schema: AlertRuleId, noun: "alert rule" }, + alertIncident: { + prefix: PublicIdPrefixes.alertIncident, + schema: AlertIncidentId, + noun: "alert incident", + }, alertDestination: { prefix: PublicIdPrefixes.alertDestination, schema: AlertDestinationId, diff --git a/apps/ai/src/mcp/tool-descriptors.generated.ts b/apps/ai/src/mcp/tool-descriptors.generated.ts index cc79e1ed5e..11e322ff5d 100644 --- a/apps/ai/src/mcp/tool-descriptors.generated.ts +++ b/apps/ai/src/mcp/tool-descriptors.generated.ts @@ -30,13 +30,14 @@ export const MCP_TOOL_DESCRIPTORS: ReadonlyArray = [ {"name":"list_alert_rules","title":"List Alert Rules","description":"List configured alert rules with severity, signal type and condition. Use list_alert_incidents for what has fired.","inputSchema":{"type":"object","properties":{"services":{"type":"array","items":{"type":"string"},"description":"Only rules covering any of these services: scoped to them, scoped to every service, or raw_query rules whose SQL or name mentions them"},"signal_type":{"type":"string","enum":["error_rate","p95_latency","p99_latency","apdex","throughput","builder_query","raw_query"],"description":"Only rules on this signal type"},"severity":{"type":"string","enum":["warning","critical"],"description":"Only rules with this severity"},"enabled_only":{"type":"boolean","description":"Only enabled rules"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"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}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"services":{"type":"array","items":{"type":"string"}},"signalType":{"type":"string"},"severity":{"type":"string"},"enabledOnly":{"type":"boolean"}},"required":["rules","total"],"additionalProperties":true},"annotations":{"title":"List Alert Rules","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"list_alert_destinations","title":"List Alert Destinations","description":"List the notification destinations a rule can deliver to, with id, type and delivery health. Pass the ids as destination_ids to create_alert_rule / update_alert_rule.","inputSchema":{"type":"object","properties":{"type":{"type":"string","enum":["pagerduty","webhook","discord","telegram","google-chat","email","chat","linear"],"description":"Only destinations of this type"},"enabled_only":{"type":"boolean","description":"Only enabled destinations"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"destinations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"type":{"type":"string"},"enabled":{"type":"boolean"},"summary":{"type":"string"},"channelLabel":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastTestedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastTestError":{"anyOf":[{"type":"string"},{"type":"null"}]},"disabledReason":{"anyOf":[{"type":"string"},{"type":"null"}]},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","type","enabled","summary","channelLabel","lastTestedAt","lastTestError","disabledReason","createdAt","updatedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"type":{"type":"string"},"enabledOnly":{"type":"boolean"}},"required":["destinations","total"],"additionalProperties":true},"annotations":{"title":"List Alert Destinations","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"get_alert_rule","title":"Get Alert Rule","description":"Get full configuration details of a specific alert rule including thresholds, service filters, evaluation settings, and notification destinations. Use list_alert_rules to find rule IDs.","inputSchema":{"type":"object","properties":{"rule_id":{"type":"string","description":"Alert rule ID: its UUID or its REST id (alrt_\u2026)"}},"required":["rule_id"],"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"},"excludeServiceNames":{"type":"array","items":{"type":"string"}},"groupBy":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]},"minimumSampleCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"noDataBehavior":{"type":"string"},"consecutiveBreachesRequired":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"consecutiveHealthyRequired":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"renotifyIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"apdexThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"queryBuilderDraft":{"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},{"type":"null"}]},"rawQuerySql":{"anyOf":[{"type":"string"},{"type":"null"}]},"rawQueryReducer":{"anyOf":[{"type":"string"},{"type":"null"}]},"thresholdUpper":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"notificationTitle":{"anyOf":[{"type":"string"},{"type":"null"}]},"notificationBody":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastEvaluationError":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastEvaluationErrorKind":{"anyOf":[{"type":"string"},{"type":"null"}]},"slowedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastEvaluatedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"destinations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]},"enabled":{"type":"boolean"}},"required":["id","name","type","enabled"],"additionalProperties":true}}},"required":["id","name","enabled","severity","serviceNames","environments","signalType","comparator","threshold","windowMinutes","evaluationIntervalMinutes","slowedIntervalMinutes","destinationIds","createdAt","updatedAt","excludeServiceNames","groupBy","minimumSampleCount","noDataBehavior","consecutiveBreachesRequired","consecutiveHealthyRequired","renotifyIntervalMinutes","apdexThresholdMs","queryBuilderDraft","rawQuerySql","rawQueryReducer"],"additionalProperties":true}},"required":["rule"],"additionalProperties":true},"annotations":{"title":"Get Alert Rule","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, - {"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_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"}]},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"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"}]},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"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":"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":"acknowledge_alert_incident","title":"Acknowledge Alert Incident","description":"Mark an open alert incident as acknowledged (someone is on it) or, with acknowledged=false, clear the acknowledgement. While acknowledged, reminders pause; an escalation to a higher severity still notifies and clears it. Only open incidents; ids from list_alert_incidents.","inputSchema":{"type":"object","properties":{"incident_id":{"type":"string","description":"Alert incident ID: its UUID or its REST id (inc_\u2026)"},"acknowledged":{"type":"boolean","description":"false to unacknowledge. Default true."}},"required":["incident_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"ruleName":{"type":"string"},"status":{"type":"string"},"acknowledged":{"type":"boolean"},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"anyOf":[{"type":"string"},{"type":"null"}]},"changed":{"type":"boolean"}},"required":["id","ruleName","status","acknowledged","acknowledgedAt","acknowledgedBy","changed"],"additionalProperties":true},"annotations":{"title":"Acknowledge Alert Incident","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-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts index a0b526b6ae..2d446f0d4e 100644 --- a/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts @@ -135,6 +135,10 @@ const incident = (overrides: Record) => ({ lastNotifiedAt: null, holdReason: null, heldSince: null, + acknowledgedAt: null, + acknowledgedByUserId: null, + acknowledgedByName: null, + acknowledgedVia: null, errorIssueId: null, ...overrides, }) diff --git a/apps/ai/src/mcp/tools/acknowledge-alert-incident.test.ts b/apps/ai/src/mcp/tools/acknowledge-alert-incident.test.ts new file mode 100644 index 0000000000..7e0e0a3295 --- /dev/null +++ b/apps/ai/src/mcp/tools/acknowledge-alert-incident.test.ts @@ -0,0 +1,129 @@ +import { afterEach, assert, describe, it } from "@effect/vitest" +import { ConfigProvider, Effect, Layer, Schema } from "effect" +import { OrgId, UserId } from "@maple/domain/http" +import { Env } from "@maple/backend/platform/Env" +import { EdgeCacheServiceLive } from "@maple/backend/platform/CacheBackendLive" +import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@maple/backend/platform/test-pglite" +import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import { registerAcknowledgeAlertIncidentTool } from "./acknowledge-alert-incident" +import type { McpToolError, McpToolRegistrar } from "./types" +import type { McpToolRequirements } from "./runtime-requirements" +import { unimplementedMcpToolServices } from "../../../test/mcp/unimplemented-tool-services" + +const trackedDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(trackedDbs)) + +const ORG = "org_mcp_ack" +const OPEN = "00000000-0000-4000-8000-0000000000b1" +const RESOLVED = "00000000-0000-4000-8000-0000000000b2" + +type Invoke = ( + params: Readonly>, +) => Effect.Effect + +const acknowledgeTool = (): Invoke => { + let invoke: Invoke = () => Effect.die("unregistered") + const registrar: McpToolRegistrar = { + define: (spec) => { + invoke = (params) => + Schema.decodeEffect(spec.parameters)(params).pipe(Effect.orDie, Effect.flatMap(spec.handler)) + }, + } + registerAcknowledgeAlertIncidentTool(registrar) + return (params) => invoke(params) +} + +const layerFor = (testDb: TestDb) => + Layer.mergeAll( + unimplementedMcpToolServices, + AlertIncidentAckService.layer, + Layer.succeed(CurrentMcpTenant, { + orgId: Schema.decodeSync(OrgId)(ORG), + userId: Schema.decodeSync(UserId)("user_mcp_ack"), + roles: [], + authMode: "self_hosted", + }), + ).pipe( + Layer.provide(Layer.mergeAll(testDb.layer, EdgeCacheServiceLive)), + Layer.provideMerge(Env.layer), + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + TINYBIRD_HOST: "https://api.tinybird.co", + TINYBIRD_TOKEN: "test-token", + MAPLE_AUTH_MODE: "self_hosted", + MAPLE_ROOT_PASSWORD: "test-root-password", + MAPLE_DEFAULT_ORG_ID: "default", + MAPLE_INGEST_KEY_ENCRYPTION_KEY: Buffer.alloc(32, 1).toString("base64"), + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: "maple-test-lookup-secret", + INTERNAL_SERVICE_TOKEN: "test-internal-token", + }), + ), + ), + ) + +const seed = (testDb: TestDb) => + Effect.promise(() => + executeSql( + testDb, + `INSERT INTO alert_incidents + (id, org_id, rule_id, incident_key, rule_name, group_key, signal_type, severity, status, + comparator, threshold, first_triggered_at, last_triggered_at, resolved_at, + dedupe_key, created_at, updated_at) + VALUES + ($1, $3, '00000000-0000-4000-8000-0000000000b0', 'mcp-open', 'Checkout errors', '__total__', + 'error_rate', 'critical', 'open', 'gt', 5, now(), now(), null, 'm-open', now(), now()), + ($2, $3, '00000000-0000-4000-8000-0000000000b0', 'mcp-res', 'Checkout errors', '__total__', + 'error_rate', 'critical', 'resolved', 'gt', 5, now(), now(), now(), 'm-res', now(), now())`, + [OPEN, RESOLVED, ORG], + ), + ) + +describe("acknowledge_alert_incident", () => { + it.effect("acknowledges by REST id, unacknowledges, and reports a no-op", () => { + const testDb = createTestDb(trackedDbs) + const call = acknowledgeTool() + return Effect.gen(function* () { + yield* seed(testDb) + const acked = yield* call({ incident_id: OPEN }) + assert.deepInclude(acked, { + id: OPEN, + status: "open", + acknowledged: true, + changed: true, + // MCP has no member directory: the source stands in for the name. + acknowledgedBy: "someone via mcp", + }) + assert.deepInclude(yield* call({ incident_id: OPEN, acknowledged: "true" }), { changed: false }) + assert.deepInclude(yield* call({ incident_id: OPEN, acknowledged: false }), { + acknowledged: false, + acknowledgedBy: null, + changed: true, + }) + }).pipe(Effect.provide(layerFor(testDb))) + }) + + it.effect("names a resolved or unknown incident as invalid input on incident_id", () => { + const testDb = createTestDb(trackedDbs) + const call = acknowledgeTool() + return Effect.gen(function* () { + yield* seed(testDb) + const resolved = yield* Effect.flip(call({ incident_id: RESOLVED })) + assert.deepInclude(resolved, { + _tag: "@maple/mcp/errors/McpInvalidInputError", + parameter: "incident_id", + }) + const unknown = yield* Effect.flip(call({ incident_id: "00000000-0000-4000-8000-0000000000ff" })) + assert.deepInclude(unknown, { + _tag: "@maple/mcp/errors/McpInvalidInputError", + parameter: "incident_id", + }) + const malformed = yield* Effect.flip(call({ incident_id: "dest_notAnIncident" })) + assert.deepInclude(malformed, { + _tag: "@maple/mcp/errors/McpInvalidInputError", + parameter: "incident_id", + }) + }).pipe(Effect.provide(layerFor(testDb))) + }) +}) diff --git a/apps/ai/src/mcp/tools/acknowledge-alert-incident.ts b/apps/ai/src/mcp/tools/acknowledge-alert-incident.ts new file mode 100644 index 0000000000..3491456709 --- /dev/null +++ b/apps/ai/src/mcp/tools/acknowledge-alert-incident.ts @@ -0,0 +1,92 @@ +import { McpInvalidInputError, McpQueryError, type McpToolRegistrar } from "./types" +import { Effect, Schema } from "effect" +import { AcknowledgeAlertIncidentOutput } from "@maple/domain/mcp-outputs" +import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import { ackLabel } from "../lib/alert-rules" +import * as P from "../lib/params" +import { doc } from "../lib/tool-doc" + +export function registerAcknowledgeAlertIncidentTool(server: McpToolRegistrar) { + server.define({ + name: "acknowledge_alert_incident", + title: "Acknowledge Alert Incident", + description: + "Mark an open alert incident as acknowledged (someone is on it) or, with acknowledged=false, clear the acknowledgement. " + + "While acknowledged, reminders pause; an escalation to a higher severity still notifies and clears it. " + + "Only open incidents; ids from list_alert_incidents.", + parameters: Schema.Struct({ + incident_id: P.resourceId("alertIncident", "Alert incident ID"), + acknowledged: P.optionalFlag("false to unacknowledge. Default true."), + }), + output: AcknowledgeAlertIncidentOutput, + hints: { readOnly: false, destructive: false, idempotent: true }, + phrases: ["Acknowledging an alert incident"], + handler: Effect.fn("McpTool.acknowledgeAlertIncident")(function* (params) { + const incidentId = yield* P.decodeResourceId("alertIncident", "incident_id")(params.incident_id) + const tenant = yield* CurrentMcpTenant + const acks = yield* AlertIncidentAckService + const acknowledge = params.acknowledged !== false + const actor = { userId: tenant.userId, name: null, source: "mcp" as const } + + const result = yield* ( + acknowledge + ? acks.acknowledge(tenant.orgId, incidentId, actor) + : acks.unacknowledge(tenant.orgId, incidentId, actor) + ).pipe( + Effect.catchTags({ + "@maple/http/errors/AlertIncidentNotFoundError": () => + Effect.fail( + new McpInvalidInputError({ + message: `No alert incident ${params.incident_id}. List incidents with list_alert_incidents.`, + parameter: "incident_id", + }), + ), + "@maple/http/errors/AlertIncidentResolvedError": (error) => + Effect.fail( + new McpInvalidInputError({ message: error.message, parameter: "incident_id" }), + ), + "@maple/http/errors/AlertPersistenceError": (error) => + Effect.fail( + new McpQueryError({ + message: `${error._tag}: ${error.message}`, + pipeName: "acknowledge_alert_incident", + cause: error, + }), + ), + }), + ) + + const { incident } = result + return { + id: incident.id, + ruleName: incident.ruleName, + status: incident.status, + acknowledged: incident.acknowledgedAt !== null, + acknowledgedAt: incident.acknowledgedAt, + acknowledgedBy: ackLabel(incident), + changed: result.changed, + } + }), + render: (output) => ({ + title: output.acknowledged ? "Incident Acknowledged" : "Incident Unacknowledged", + blocks: [ + ...(output.changed + ? [] + : [ + doc.text( + `Already ${output.acknowledged ? "acknowledged" : "unacknowledged"}; nothing changed.`, + ), + ]), + doc.fields([ + ["Incident", output.id], + ["Rule", output.ruleName], + ["Status", output.status], + ["Acknowledged by", output.acknowledgedBy ?? undefined], + ["Acknowledged at", output.acknowledgedAt ?? undefined], + ]), + ], + next: [doc.next("get_incident_timeline", {}, "the incident timeline")], + }), + }) +} diff --git a/apps/ai/src/mcp/tools/get-incident-timeline.ts b/apps/ai/src/mcp/tools/get-incident-timeline.ts index 3b89f78735..8534cc0232 100644 --- a/apps/ai/src/mcp/tools/get-incident-timeline.ts +++ b/apps/ai/src/mcp/tools/get-incident-timeline.ts @@ -3,7 +3,13 @@ import { Effect, 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 { ALERT_INCIDENT_STATUSES, ALERT_SEVERITIES, formatCondition, parseRuleId } from "../lib/alert-rules" +import { + ackLabel, + 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" @@ -82,6 +88,8 @@ export function registerGetIncidentTimelineTool(server: McpToolRegistrar) { lastTriggeredAt: i.lastTriggeredAt, resolvedAt: i.resolvedAt, lastNotifiedAt: i.lastNotifiedAt, + acknowledgedAt: i.acknowledgedAt, + acknowledgedBy: ackLabel(i), })), total: incidents.length, openCount: Arr.filter(incidents, (i) => i.status === "open").length, @@ -120,6 +128,12 @@ export function registerGetIncidentTimelineTool(server: McpToolRegistrar) { ["Last triggered", formatTimestamp(inc.lastTriggeredAt)], ["Resolved at", formatTimestamp(inc.resolvedAt)], ["Last notified", formatTimestamp(inc.lastNotifiedAt)], + [ + "Acknowledged", + inc.acknowledgedAt == null + ? undefined + : `${formatTimestamp(inc.acknowledgedAt)} by ${inc.acknowledgedBy ?? "someone"}`, + ], ["Incident ID", inc.id], ["Rule ID", inc.ruleId], ]), diff --git a/apps/ai/src/mcp/tools/list-alert-incidents.ts b/apps/ai/src/mcp/tools/list-alert-incidents.ts index 55e689c341..50cba7c9b0 100644 --- a/apps/ai/src/mcp/tools/list-alert-incidents.ts +++ b/apps/ai/src/mcp/tools/list-alert-incidents.ts @@ -4,7 +4,7 @@ import { Clock, Effect, Schema, Array as Arr, String as Str } from "effect" import { ListAlertIncidentsOutput } from "@maple/domain/mcp-outputs" import { CurrentMcpTenant } from "../lib/query-warehouse" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" -import { ALERT_INCIDENT_STATUSES, ALERT_SEVERITIES, formatCondition } from "../lib/alert-rules" +import { ackLabel, ALERT_INCIDENT_STATUSES, ALERT_SEVERITIES, formatCondition } from "../lib/alert-rules" import * as P from "../lib/params" import { doc } from "../lib/tool-doc" import { parseWarehouseDateTime } from "@maple/query-engine" @@ -105,6 +105,8 @@ export function registerListAlertIncidentsTool(server: McpToolRegistrar) { status: i.status, holdReason: i.holdReason, heldSince: i.heldSince, + acknowledgedAt: i.acknowledgedAt, + acknowledgedBy: ackLabel(i), threshold: i.threshold, comparator: i.comparator, firstTriggeredAt: i.firstTriggeredAt, @@ -199,7 +201,9 @@ export function registerListAlertIncidentsTool(server: McpToolRegistrar) { i.severity, i.holdReason != null ? `open (waiting on data: ${i.holdReason})` - : i.status, + : i.acknowledgedAt != null + ? `open (acknowledged by ${i.acknowledgedBy ?? "someone"})` + : i.status, i.groupKey ?? "all", i.signalType, formatCondition(i), diff --git a/apps/ai/src/mcp/tools/mutating.ts b/apps/ai/src/mcp/tools/mutating.ts index 8f50153e5d..f97be328e6 100644 --- a/apps/ai/src/mcp/tools/mutating.ts +++ b/apps/ai/src/mcp/tools/mutating.ts @@ -18,6 +18,7 @@ export const MUTATING_TOOL_NAMES: ReadonlySet = new Set([ "create_alert_rule", "update_alert_rule", "delete_alert_rule", + "acknowledge_alert_incident", // 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..2d974ac9f0 100644 --- a/apps/ai/src/mcp/tools/registry.ts +++ b/apps/ai/src/mcp/tools/registry.ts @@ -10,6 +10,7 @@ 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 { registerAcknowledgeAlertIncidentTool } from "./acknowledge-alert-incident" import { registerCreateDashboardTool } from "./create-dashboard" import { registerDescribeDashboardSchemaTool } from "./describe-dashboard-schema" import { registerDiagnoseServiceTool } from "./diagnose-service" @@ -429,6 +430,7 @@ const collectMapleToolDefinitions = (): ReadonlyArray => { registerCreateAlertRuleTool(registrar) registerUpdateAlertRuleTool(registrar) registerDeleteAlertRuleTool(registrar) + registerAcknowledgeAlertIncidentTool(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..340927704f 100644 --- a/apps/ai/src/mcp/tools/runtime-requirements.ts +++ b/apps/ai/src/mcp/tools/runtime-requirements.ts @@ -2,6 +2,7 @@ import type { AuditLogService } from "@maple/backend/services/audit/AuditLogServ import type { AgentFeedbackService } from "@maple/backend/services/feedback/AgentFeedbackService" import type { AlertsService } from "@maple/backend/services/alerts/AlertsService" import type { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" +import type { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import type { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" import type { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService" import type { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService" @@ -45,6 +46,7 @@ export type McpToolRuntimeRequirements = | AuditLogService | DependencyScanReader | AlertReadModelsService + | AlertIncidentAckService | AlertRulesService | DashboardPersistenceService | ErrorActorsService diff --git a/apps/ai/test/mcp/unimplemented-tool-services.ts b/apps/ai/test/mcp/unimplemented-tool-services.ts index 8e2574fd24..ce8079677c 100644 --- a/apps/ai/test/mcp/unimplemented-tool-services.ts +++ b/apps/ai/test/mcp/unimplemented-tool-services.ts @@ -5,6 +5,7 @@ import { AuditLogService } from "@maple/backend/services/audit/AuditLogService" import { AgentFeedbackService } from "@maple/backend/services/feedback/AgentFeedbackService" import { AlertsService } from "@maple/backend/services/alerts/AlertsService" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" +import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" import { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService" import { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService" @@ -41,6 +42,7 @@ export const unimplementedMcpToolServices = Layer.mergeAll( Layer.mock(AgentFeedbackService)({}), Layer.mock(AlertsService)({}), Layer.mock(AlertReadModelsService)({}), + Layer.mock(AlertIncidentAckService)({}), Layer.mock(AlertRulesService)({}), Layer.mock(DashboardPersistenceService)({}), Layer.mock(ErrorActorsService)({}), diff --git a/apps/api/src/routes/v2/alert-incidents.http.ts b/apps/api/src/routes/v2/alert-incidents.http.ts index dd7bbf2538..6ba0a28a02 100644 --- a/apps/api/src/routes/v2/alert-incidents.http.ts +++ b/apps/api/src/routes/v2/alert-incidents.http.ts @@ -1,10 +1,16 @@ import { HttpApiBuilder } from "effect/http-api" -import type { AlertIncidentDocument } from "@maple/domain/http" +import type { AlertIncidentDocument, AlertIncidentEventDocument } from "@maple/domain/http" import { CurrentTenant } from "@maple/domain/http" -import type { V2AlertIncident } from "@maple/domain/http/v2" +import type { V2AlertIncident, V2AlertIncidentEvent } from "@maple/domain/http/v2" import { MapleApiV2, paginateOffsetQuery } from "@maple/domain/http/v2" -import { Effect, Array as Arr } from "effect" +import { Duration, Effect, Option, Array as Arr } from "effect" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" +import { + AlertIncidentAckService, + type AlertIncidentActor, +} from "@maple/backend/services/alerts/AlertIncidentAckService" +import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" +import { CurrentAuditActor } from "@maple/backend/services/auth/audit-actor" const toV2Incident = (doc: AlertIncidentDocument): V2AlertIncident => ({ id: doc.id, @@ -29,12 +35,58 @@ const toV2Incident = (doc: AlertIncidentDocument): V2AlertIncident => ({ last_notified_at: doc.lastNotifiedAt, hold_reason: doc.holdReason, held_since: doc.heldSince, + acknowledged_at: doc.acknowledgedAt, + acknowledged_by: + doc.acknowledgedAt === null + ? null + : { user_id: doc.acknowledgedByUserId, name: doc.acknowledgedByName }, + acknowledged_via: doc.acknowledgedVia, error_issue_id: doc.errorIssueId, }) +const toV2Event = (doc: AlertIncidentEventDocument): V2AlertIncidentEvent => ({ + id: doc.id, + object: "alert_incident_event", + incident_id: doc.incidentId, + type: doc.type, + actor: + doc.actorUserId === null && doc.actorName === null + ? null + : { user_id: doc.actorUserId, name: doc.actorName }, + source: doc.source, + created_at: doc.createdAt, +}) + export const HttpV2AlertIncidentsLive = HttpApiBuilder.group(MapleApiV2, "alertIncidents", (handlers) => Effect.gen(function* () { const readModels = yield* AlertReadModelsService + const acks = yield* AlertIncidentAckService + const members = yield* OrgMembersService + + // Who is acknowledging, with a name recorded alongside so every surface can show it without a + // directory lookup. An API key is named by its label; a session by the member directory, best + // effort: a slow or unconfigured directory records the ack without a name. + const callerActor = Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const credential = yield* CurrentAuditActor + const name = + credential?.type === "api_key" + ? (credential.label ?? null) + : Option.getOrNull( + (yield* members + .resolveMembers(tenant.orgId, [tenant.userId]) + .pipe(Effect.timeout(Duration.seconds(3)), Effect.option)).pipe( + Option.flatMap(Arr.head), + Option.map((member) => member.name ?? member.email), + ), + ) + const actor: AlertIncidentActor = { + userId: tenant.userId, + name, + source: credential?.source === "dashboard" ? "web" : "api", + } + return { orgId: tenant.orgId, actor } + }) return handlers .handle("list", ({ query }) => @@ -45,6 +97,9 @@ export const HttpV2AlertIncidentsLive = HttpApiBuilder.group(MapleApiV2, "alertI .listIncidents(tenant.orgId, { ...(query.status !== undefined ? { status: query.status } : undefined), ...(query.rule_id !== undefined ? { ruleId: query.rule_id } : undefined), + ...(query.acknowledged !== undefined + ? { acknowledged: query.acknowledged === "true" } + : undefined), limit, offset, }) @@ -61,5 +116,26 @@ export const HttpV2AlertIncidentsLive = HttpApiBuilder.group(MapleApiV2, "alertI return toV2Incident(incident) }), ) + .handle("acknowledge", ({ params }) => + Effect.gen(function* () { + const { orgId, actor } = yield* callerActor + const result = yield* acks.acknowledge(orgId, params.id, actor) + return toV2Incident(result.incident) + }).pipe(Effect.withSpan("HttpV2AlertIncidents.acknowledge")), + ) + .handle("unacknowledge", ({ params }) => + Effect.gen(function* () { + const { orgId, actor } = yield* callerActor + const result = yield* acks.unacknowledge(orgId, params.id, actor) + return toV2Incident(result.incident) + }).pipe(Effect.withSpan("HttpV2AlertIncidents.unacknowledge")), + ) + .handle("listEvents", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const events = yield* acks.listEvents(tenant.orgId, params.id) + return { object: "list" as const, data: Arr.map(events, toV2Event) } + }), + ) }), ) diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index 0c49db26a0..facfbb506f 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -1146,6 +1146,114 @@ describe("v2 alerts over HTTP", () => { await harness.dispose() }) + it("acknowledges and unacknowledges an open incident and lists its events", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(["alerts:write"]) + const readOnly = await harness.bootstrapKey(["alerts:read"]) + await createWebhookAndRule(harness, key.secret) + await executeSql( + harness.testDb, + `insert into alert_incidents ( + id, org_id, rule_id, incident_key, rule_name, group_key, signal_type, severity, status, + comparator, threshold, first_triggered_at, last_triggered_at, resolved_at, dedupe_key, + created_at, updated_at + ) + select v.id, rules.org_id, rules.id, v.id, 'Checkout error rate', '__total__', 'error_rate', + 'critical', v.status, 'gt', 0.05, now(), now(), v.resolved_at, v.id, now(), now() + from (values + ('00000000-0000-4000-8000-0000000000a1', 'open', null::timestamptz), + ('00000000-0000-4000-8000-0000000000a2', 'resolved', now()) + ) as v(id, status, resolved_at) + cross join lateral ( + select id, org_id from alert_rules where org_id = 'org_alerts_e2e' limit 1 + ) as rules`, + ) + const list = await harness.request("GET", "/v2/alerts/incidents?status=open", key.secret) + const open = Arr.findFirst( + list.body.data, + (incident: { status: string }) => incident.status === "open", + ) + const openId: string = open._tag === "Some" ? open.value.id : "" + expect(open._tag === "Some" && open.value.acknowledged_at).toBeNull() + + const acked = await harness.request("POST", `/v2/alerts/incidents/${openId}/acknowledge`, key.secret) + expect(acked.status).toBe(200) + expect(acked.body.status).toBe("open") + expect(acked.body.acknowledged_at).toEqual(expect.any(String)) + // An API key is named by its label. + expect(acked.body.acknowledged_by).toEqual({ user_id: "user_alerts_e2e", name: "alerts-test" }) + expect(acked.body.acknowledged_via).toBe("api") + + const unacknowledgedOnly = await harness.request( + "GET", + "/v2/alerts/incidents?acknowledged=false", + key.secret, + ) + expect(unacknowledgedOnly.body.data).toEqual([]) + const acknowledgedOnly = await harness.request( + "GET", + "/v2/alerts/incidents?acknowledged=true", + key.secret, + ) + expect(Arr.map(acknowledgedOnly.body.data, (incident: { id: string }) => incident.id)).toEqual([ + openId, + ]) + + // alerts:read can see the history but not change it. + const denied = await harness.request( + "POST", + `/v2/alerts/incidents/${openId}/unacknowledge`, + readOnly.secret, + ) + expect(denied.status).toBe(403) + + const unacked = await harness.request( + "POST", + `/v2/alerts/incidents/${openId}/unacknowledge`, + key.secret, + ) + expect(unacked.status).toBe(200) + expect(unacked.body.acknowledged_at).toBeNull() + expect(unacked.body.acknowledged_by).toBeNull() + + const events = await harness.request("GET", `/v2/alerts/incidents/${openId}/events`, readOnly.secret) + expect(events.status).toBe(200) + expect(Arr.map(events.body.data, (event: { type: string }) => event.type)).toEqual([ + "acknowledged", + "unacknowledged", + ]) + expect(events.body.data[0]).toMatchObject({ + object: "alert_incident_event", + incident_id: openId, + actor: { user_id: "user_alerts_e2e", name: "alerts-test" }, + source: "api", + }) + expect(events.body.data[0].id).toMatch(/^evt_/) + + const all = await harness.request("GET", "/v2/alerts/incidents", key.secret) + const resolved = Arr.findFirst( + all.body.data, + (incident: { status: string }) => incident.status === "resolved", + ) + const resolvedId: string = resolved._tag === "Some" ? resolved.value.id : "" + const conflict = await harness.request( + "POST", + `/v2/alerts/incidents/${resolvedId}/acknowledge`, + key.secret, + ) + expect(conflict.status).toBe(409) + expect(conflict.body.error).toMatchObject({ code: "alert_incident_resolved" }) + + const missing = await harness.request( + "POST", + "/v2/alerts/incidents/inc_tC4d9V79DCDzgbGKhAnff9/acknowledge", + key.secret, + ) + expect(missing.status).toBe(404) + + await harness.dispose() + }) + it("enforces the alerts scope family and rejects malformed ids", async () => { const harness = makeHarness() const readOnly = await harness.bootstrapKey(["alerts:read"]) diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index d6f0dd57d6..33029d908c 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -5,6 +5,7 @@ import { EdgeCacheService, MemoryCacheBackendLive } from "@maple/cache" 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 { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" import { AnomalyDetectionService } from "@maple/backend/services/alerts/AnomalyDetectionService" import { ErrorsService } from "@maple/backend/services/errors/ErrorsService" @@ -181,7 +182,11 @@ const v2GroupLayersExceptOnboardingChecklist = ( HttpV2AlertDeliveriesLive, HttpV2AlertRulesLive, HttpV2AlertDestinationsLive, - HttpV2AlertIncidentsLive, + // Real ack service: it needs only the Database. API keys are named by their label, so the + // empty member directory is never asked for one. + HttpV2AlertIncidentsLive.pipe( + Layer.provide(Layer.merge(AlertIncidentAckService.layer, OrgMembersServiceStubLayer)), + ), HttpV2IngestKeysLive, HttpV2SourcesLive, HttpV2ErrorIssuesLive, diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index 53c2b06b83..774172532c 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -9,6 +9,7 @@ import { Env } from "@maple/backend/platform/Env" 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 { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" import { AnomalyDetectionService } from "@maple/backend/services/alerts/AnomalyDetectionService" @@ -175,6 +176,7 @@ export const HttpServicesLive = Layer.mergeAll( DashboardWidgetDataService.layer, AlertDestinationsService.layer, AlertReadModelsService.layer, + AlertIncidentAckService.layer, AlertRulesService.layer, AlertsService.layer, AnomalyDetectionService.layer, diff --git a/apps/web/src/lab/alerts-fixture.ts b/apps/web/src/lab/alerts-fixture.ts index 6931236499..9e7ccfa707 100644 --- a/apps/web/src/lab/alerts-fixture.ts +++ b/apps/web/src/lab/alerts-fixture.ts @@ -542,6 +542,10 @@ const incidentFrom = (spec: IncidentSpec, rule: AlertRuleDocument, nowMs: number lastNotifiedAt: iso(nowMs - (open ? 8 * MIN : (spec.endAgo ?? 0))), holdReason: spec.held ? "volume_collapsed" : null, heldSince: spec.held ? iso(nowMs - 58 * MIN) : null, + acknowledgedAt: null, + acknowledgedByUserId: null, + acknowledgedByName: null, + acknowledgedVia: null, errorIssueId: null, }) } diff --git a/packages/alerting-core/src/index.ts b/packages/alerting-core/src/index.ts index d19f935544..8c4620679a 100644 --- a/packages/alerting-core/src/index.ts +++ b/packages/alerting-core/src/index.ts @@ -173,6 +173,8 @@ export interface AlertLifecycleIncident { readonly firstTriggeredAtMs: number readonly lastNotifiedAtMs: number | null readonly lastDeliveredEventType: AlertEventType | null + /** Someone is on it: reminders and de-escalation notices stay quiet. */ + readonly acknowledged?: boolean } export type AlertIncidentTransition = @@ -306,6 +308,8 @@ export interface AlertLifecyclePlan { readonly inheritedNotificationAtMs: number | null /** Whether the host must advance lastNotifiedAt before queueing the event. */ readonly advanceNotificationAnchor: boolean + /** Whether the host must clear the incident's acknowledgement (an escalation pages again). */ + readonly clearAcknowledgement: boolean } export interface AlertDeliveryRetryPolicy { @@ -358,6 +362,7 @@ const noTransition = ( hold, inheritedNotificationAtMs: null, advanceNotificationAnchor: false, + clearAcknowledgement: false, }) const resolutionPlan = ( @@ -375,6 +380,7 @@ const resolutionPlan = ( hold: null, inheritedNotificationAtMs: null, advanceNotificationAnchor: false, + clearAcknowledgement: false, } } @@ -439,6 +445,7 @@ export const planAlertLifecycle = (input: AlertLifecycleInput): Effect.Effect { + const tiers = [warnAt(10), critAt(20)] + const tieredPolicy = { + consecutiveBreachesRequired: 1, + breachWindow: 1, + consecutiveHealthyRequired: 1, + renotifyIntervalMinutes: 10, + severities: ["warning" as const, "critical" as const], + } + const openAt = (severity: "warning" | "critical", acknowledged: boolean): AlertLifecycleIncident => ({ + severity, + firstTriggeredAtMs: 0, + lastNotifiedAtMs: 0, + lastDeliveredEventType: "trigger", + acknowledged, + }) + const tick = (value: number, incident: AlertLifecycleIncident) => + plan({ + policy: tieredPolicy, + evaluation: evaluate(tiers, value), + state: { consecutiveBreaches: 1, consecutiveHealthy: 0 }, + openIncident: incident, + // Well past the 10-minute reminder interval. + nowMs: 60 * 60_000, + }) + + it("sends no reminder and keeps the reminder clock while acknowledged", () => { + expect(tick(15, openAt("warning", false))).toMatchObject({ + eventType: "renotify", + advanceNotificationAnchor: true, + }) + expect(tick(15, openAt("warning", true))).toMatchObject({ + transition: "continued", + eventType: null, + advanceNotificationAnchor: false, + clearAcknowledgement: false, + }) + }) + + it("pages an escalation despite the ack, and asks the host to clear it", () => { + expect(tick(25, openAt("warning", true))).toMatchObject({ + transition: "escalated", + eventType: "trigger", + advanceNotificationAnchor: true, + clearAcknowledgement: true, + }) + expect(tick(25, openAt("warning", false))).toMatchObject({ clearAcknowledgement: false }) + }) + + it("de-escalates quietly while acknowledged", () => { + expect(tick(15, openAt("critical", true))).toMatchObject({ + transition: "deescalated", + severity: "warning", + eventType: null, + advanceNotificationAnchor: false, + clearAcknowledgement: false, + }) + }) + + it("still sends the all-clear when an acknowledged incident resolves", () => { + expect(tick(5, openAt("warning", true))).toMatchObject({ + transition: "resolved", + eventType: "resolve", + }) + }) +}) diff --git a/packages/backend/src/services/alerts/AlertIncidentAckService.test.ts b/packages/backend/src/services/alerts/AlertIncidentAckService.test.ts new file mode 100644 index 0000000000..72a81e5c21 --- /dev/null +++ b/packages/backend/src/services/alerts/AlertIncidentAckService.test.ts @@ -0,0 +1,112 @@ +import { afterEach, assert, describe, it } from "@effect/vitest" +import { AlertIncidentId, AlertRuleId, OrgId, UserId } from "@maple/domain/http" +import { Duration, Effect, Layer, Schema, Array as Arr } from "effect" +import { TestClock } from "effect/testing" +import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@maple/backend/platform/test-pglite" +import { AlertIncidentAckService, type AlertIncidentActor } from "./AlertIncidentAckService" + +const asOrgId = Schema.decodeUnknownSync(OrgId) +const asRuleId = Schema.decodeUnknownSync(AlertRuleId) +const asIncidentId = Schema.decodeUnknownSync(AlertIncidentId) +const asUserId = Schema.decodeUnknownSync(UserId) + +const ORG = asOrgId("org_alert_ack") +const OTHER_ORG = asOrgId("org_alert_ack_other") +const RULE = asRuleId("00000000-0000-4000-8000-000000000020") +const OPEN = asIncidentId("00000000-0000-4000-8000-000000000021") +const RESOLVED = asIncidentId("00000000-0000-4000-8000-000000000022") + +const dana: AlertIncidentActor = { userId: asUserId("user_dana"), name: "Dana Kim", source: "web" } +const slackClick: AlertIncidentActor = { userId: null, name: "sam", source: "slack" } + +const createdDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(createdDbs)) + +const makeLayer = (testDb: TestDb) => AlertIncidentAckService.layer.pipe(Layer.provide(testDb.layer)) + +const seed = (testDb: TestDb) => + Effect.promise(() => + executeSql( + testDb, + `INSERT INTO alert_incidents + (id, org_id, rule_id, incident_key, rule_name, group_key, signal_type, severity, status, + comparator, threshold, first_triggered_at, last_triggered_at, resolved_at, + dedupe_key, created_at, updated_at) + VALUES + ($1, $3, $4, 'ack-open', 'Checkout errors', '__total__', 'error_rate', 'critical', 'open', + 'gt', 5, '2026-11-06T09:00:00Z', '2026-11-06T09:10:00Z', null, 'd-open', now(), now()), + ($2, $3, $4, 'ack-resolved', 'Checkout errors', '__total__', 'error_rate', 'critical', 'resolved', + 'gt', 5, '2026-11-06T08:00:00Z', '2026-11-06T08:10:00Z', '2026-11-06T08:20:00Z', 'd-res', now(), now())`, + [OPEN, RESOLVED, ORG, RULE], + ), + ) + +describe("AlertIncidentAckService", () => { + it.effect("acknowledges, records who and where, and unacknowledges", () => { + const testDb = createTestDb(createdDbs) + return Effect.gen(function* () { + yield* seed(testDb) + const acks = yield* AlertIncidentAckService + + const acked = yield* acks.acknowledge(ORG, OPEN, dana) + assert.isTrue(acked.changed) + assert.strictEqual(acked.incident.status, "open") + assert.isNotNull(acked.incident.acknowledgedAt) + assert.strictEqual(acked.incident.acknowledgedByUserId, dana.userId) + assert.strictEqual(acked.incident.acknowledgedByName, "Dana Kim") + assert.strictEqual(acked.incident.acknowledgedVia, "web") + + yield* TestClock.adjust(Duration.minutes(3)) + const unacked = yield* acks.unacknowledge(ORG, OPEN, slackClick) + assert.isTrue(unacked.changed) + assert.isNull(unacked.incident.acknowledgedAt) + assert.isNull(unacked.incident.acknowledgedVia) + + const events = yield* acks.listEvents(ORG, OPEN) + assert.deepStrictEqual( + Arr.map(events, (event) => [event.type, event.actorName, event.source]), + [ + ["acknowledged", "Dana Kim", "web"], + ["unacknowledged", "sam", "slack"], + ], + ) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("writes one event when the incident is already in the requested state", () => { + const testDb = createTestDb(createdDbs) + return Effect.gen(function* () { + yield* seed(testDb) + const acks = yield* AlertIncidentAckService + + yield* acks.acknowledge(ORG, OPEN, dana) + const again = yield* acks.acknowledge(ORG, OPEN, slackClick) + assert.isFalse(again.changed) + // The first acknowledger stays on record. + assert.strictEqual(again.incident.acknowledgedByName, "Dana Kim") + + const notAcked = yield* acks + .unacknowledge(ORG, OPEN, dana) + .pipe(Effect.andThen(acks.unacknowledge(ORG, OPEN, dana))) + assert.isFalse(notAcked.changed) + assert.lengthOf(yield* acks.listEvents(ORG, OPEN), 2) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("refuses a resolved incident and hides other orgs' incidents", () => { + const testDb = createTestDb(createdDbs) + return Effect.gen(function* () { + yield* seed(testDb) + const acks = yield* AlertIncidentAckService + + const resolved = yield* Effect.flip(acks.acknowledge(ORG, RESOLVED, dana)) + assert.strictEqual(resolved._tag, "@maple/http/errors/AlertIncidentResolvedError") + + const foreign = yield* Effect.flip(acks.acknowledge(OTHER_ORG, OPEN, dana)) + assert.strictEqual(foreign._tag, "@maple/http/errors/AlertIncidentNotFoundError") + const foreignEvents = yield* Effect.flip(acks.listEvents(OTHER_ORG, OPEN)) + assert.strictEqual(foreignEvents._tag, "@maple/http/errors/AlertIncidentNotFoundError") + assert.lengthOf(yield* acks.listEvents(ORG, OPEN), 0) + }).pipe(Effect.provide(makeLayer(testDb))) + }) +}) diff --git a/packages/backend/src/services/alerts/AlertIncidentAckService.ts b/packages/backend/src/services/alerts/AlertIncidentAckService.ts new file mode 100644 index 0000000000..5655e744ad --- /dev/null +++ b/packages/backend/src/services/alerts/AlertIncidentAckService.ts @@ -0,0 +1,227 @@ +/** + * Acknowledging an open alert incident ("someone is on it"). The incident row carries the + * current acknowledgement, which the scheduler reads to hold reminders; `alert_incident_events` + * carries the history, which the timeline shows and on-call escalation reads. + */ +import { + AlertIncidentAckSource, + AlertIncidentEventDocument, + AlertIncidentEventId, + AlertIncidentEventType, + AlertIncidentNotFoundError, + AlertIncidentResolvedError, + type AlertIncidentDocument, + type AlertIncidentId, + type AlertPersistenceError, + type OrgId, + type UserId, +} from "@maple/domain/http" +import * as PG from "@maple-dev/effect-orm/postgres" +import { AlertIncidentEvents, AlertIncidents } from "@maple/db/tables" +import { Context, Effect, Layer, Option, Schema, Array as Arr } from "effect" +import { Database } from "@maple/backend/platform/DatabaseLive" +import { makeDbExecute } from "@maple/backend/platform/db-execute" +import { makePersistenceError } from "./alert-persistence" +import { AlertRuntime } from "./AlertRuntime" +import { rowToIncidentDocument } from "./AlertReadModelsService" + +/** Who acknowledged, and from where. A click from an unlinked chat account has a name and no user. */ +export interface AlertIncidentActor { + readonly userId: UserId | null + readonly name: string | null + readonly source: AlertIncidentAckSource +} + +export interface AlertIncidentAckResult { + readonly incident: AlertIncidentDocument + /** False when the incident was already in the requested state: nothing was written. */ + readonly changed: boolean +} + +type AckError = AlertIncidentNotFoundError | AlertIncidentResolvedError | AlertPersistenceError + +export interface AlertIncidentAckServiceApi { + readonly acknowledge: ( + orgId: OrgId, + incidentId: AlertIncidentId, + actor: AlertIncidentActor, + ) => Effect.Effect + readonly unacknowledge: ( + orgId: OrgId, + incidentId: AlertIncidentId, + actor: AlertIncidentActor, + ) => Effect.Effect + /** The incident's activity, oldest first. */ + readonly listEvents: ( + orgId: OrgId, + incidentId: AlertIncidentId, + ) => Effect.Effect< + ReadonlyArray, + AlertIncidentNotFoundError | AlertPersistenceError + > +} + +const decodeEventId = Schema.decodeUnknownSync(AlertIncidentEventId) +const decodeEventType = Schema.decodeUnknownOption(AlertIncidentEventType) +const decodeSource = Schema.decodeUnknownOption(AlertIncidentAckSource) +const decodeIso = Schema.decodeUnknownSync(AlertIncidentEventDocument.fields.createdAt) + +export class AlertIncidentAckService extends Context.Service< + AlertIncidentAckService, + AlertIncidentAckServiceApi +>()("@maple/backend/services/alerts/AlertIncidentAckService", { + make: Effect.gen(function* () { + const database = yield* Database + const dbExecute = makeDbExecute(database, "AlertIncidentAckService", makePersistenceError) + + const findIncident = (orgId: OrgId, incidentId: AlertIncidentId) => + dbExecute((db) => + db.run( + PG.from(AlertIncidents) + .select() + .where(($) => [$.orgId.eq(orgId), $.id.eq(incidentId)]) + .limit(1), + ), + ).pipe( + Effect.flatMap((rows) => + Effect.fromOption(Arr.head(rows)).pipe( + Effect.mapError( + () => + new AlertIncidentNotFoundError({ + message: `No such alert incident: '${incidentId}'`, + incidentId, + }), + ), + ), + ), + ) + + /** + * One guarded UPDATE per change, so two people clicking at once write one event: the loser + * matches no row and reads back the state the winner left. + */ + const setAcknowledged = (acknowledged: boolean) => + Effect.fn( + acknowledged + ? "AlertIncidentAckService.acknowledge" + : "AlertIncidentAckService.unacknowledge", + )(function* (orgId: OrgId, incidentId: AlertIncidentId, actor: AlertIncidentActor) { + yield* Effect.annotateCurrentSpan({ + orgId, + "maple.alert.incident_id": incidentId, + "maple.alert.ack_source": actor.source, + }) + const runtime = yield* AlertRuntime + const now = yield* runtime.now + const eventId = decodeEventId(runtime.makeUuid()) + const updated = yield* dbExecute((db) => + db.transaction( + Effect.gen(function* () { + const rows = yield* db.run( + PG.update(AlertIncidents) + .set( + acknowledged + ? { + acknowledgedAt: now, + acknowledgedByUserId: actor.userId, + acknowledgedByName: actor.name, + acknowledgedVia: actor.source, + updatedAt: now, + } + : { + acknowledgedAt: null, + acknowledgedByUserId: null, + acknowledgedByName: null, + acknowledgedVia: null, + updatedAt: now, + }, + ) + .where(($) => [ + $.orgId.eq(orgId), + $.id.eq(incidentId), + $.status.eq("open"), + acknowledged + ? $.acknowledgedAt.isNull() + : $.acknowledgedAt.isNotNull(), + ]) + .returning(), + ) + if (rows.length > 0) { + yield* db.run( + PG.insertInto(AlertIncidentEvents).values({ + id: eventId, + orgId, + incidentId, + type: acknowledged ? "acknowledged" : "unacknowledged", + actorUserId: actor.userId, + actorName: actor.name, + source: actor.source, + createdAt: now, + }), + ) + } + return rows + }), + ), + ) + const row = Arr.head(updated) + if (Option.isSome(row)) { + yield* Effect.annotateCurrentSpan("maple.alert.ack_changed", true) + return { incident: rowToIncidentDocument(row.value), changed: true } + } + const current = yield* findIncident(orgId, incidentId) + if (current.status !== "open") { + return yield* new AlertIncidentResolvedError({ + message: `Alert incident '${incidentId}' is resolved; only an open incident can be ${acknowledged ? "acknowledged" : "unacknowledged"}.`, + incidentId, + }) + } + yield* Effect.annotateCurrentSpan("maple.alert.ack_changed", false) + return { incident: rowToIncidentDocument(current), changed: false } + }) + + const listEvents = Effect.fn("AlertIncidentAckService.listEvents")(function* ( + orgId: OrgId, + incidentId: AlertIncidentId, + ) { + yield* Effect.annotateCurrentSpan({ orgId, "maple.alert.incident_id": incidentId }) + yield* findIncident(orgId, incidentId) + const rows = yield* dbExecute((db) => + db.run( + PG.from(AlertIncidentEvents) + .select() + .where(($) => [$.orgId.eq(orgId), $.incidentId.eq(incidentId)]) + .orderBy(["createdAt", "asc"], ["id", "asc"]) + .limit(500), + ), + ) + // A type this build does not know (a newer writer) is skipped rather than failing the page. + return Arr.getSomes( + Arr.map(rows, (row) => + Option.map( + decodeEventType(row.type), + (type) => + new AlertIncidentEventDocument({ + id: row.id, + incidentId: row.incidentId, + type, + actorUserId: row.actorUserId, + actorName: row.actorName, + source: + row.source === null ? null : Option.getOrNull(decodeSource(row.source)), + createdAt: decodeIso(new Date(row.createdAt).toISOString()), + }), + ), + ), + ) + }) + + return { + acknowledge: setAcknowledged(true), + unacknowledge: setAcknowledged(false), + listEvents, + } satisfies AlertIncidentAckServiceApi + }), +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend/src/services/alerts/AlertReadModelsService.ts b/packages/backend/src/services/alerts/AlertReadModelsService.ts index 7240673eb1..bfcf088c73 100644 --- a/packages/backend/src/services/alerts/AlertReadModelsService.ts +++ b/packages/backend/src/services/alerts/AlertReadModelsService.ts @@ -11,6 +11,7 @@ import { AlertEventType as AlertEventTypeSchema, AlertIncidentDocument, AlertIncidentsListResponse, + AlertIncidentAckSource, AlertIncidentHoldReason, AlertIncidentStatus, AlertIncidentTransition as AlertIncidentTransitionSchema, @@ -74,6 +75,8 @@ export interface AlertChecksSummary { export interface ListAlertIncidentsOptions { readonly status?: AlertIncidentStatus readonly ruleId?: AlertRuleId + /** Open incidents only, split by whether someone has acknowledged them. */ + readonly acknowledged?: boolean readonly limit?: number readonly offset?: number } @@ -145,6 +148,8 @@ const decodeAlertIncidentHoldReasonSync = Schema.decodeUnknownSync(AlertIncident const decodeAlertEventTypeSync = Schema.decodeUnknownSync(AlertEventTypeSchema) const decodeErrorIssueIdSync = Schema.decodeUnknownSync(AlertIncidentDocument.fields.errorIssueId) const decodeAlertDeliveryStatusSync = Schema.decodeUnknownSync(AlertDeliveryStatus) +// An unknown source (a newer writer) reads as none rather than failing the page. +const decodeAlertIncidentAckSource = Schema.decodeUnknownOption(AlertIncidentAckSource) type IsoDateTimeValue = Schema.Schema.Type @@ -153,7 +158,7 @@ const toIso = (value: number | null | undefined): IsoDateTimeValue | null => const makeValidationError = (message: string) => new AlertValidationError({ message, details: [] }) -const rowToIncidentDocument = (row: AlertIncidentRow) => +export const rowToIncidentDocument = (row: AlertIncidentRow) => new AlertIncidentDocument({ id: decodeAlertIncidentIdSync(row.id), ruleId: decodeAlertRuleIdSync(row.ruleId), @@ -177,6 +182,13 @@ const rowToIncidentDocument = (row: AlertIncidentRow) => lastNotifiedAt: toIso(row.lastNotifiedAt), holdReason: row.holdReason != null ? decodeAlertIncidentHoldReasonSync(row.holdReason) : null, heldSince: toIso(row.heldSince), + acknowledgedAt: toIso(row.acknowledgedAt), + acknowledgedByUserId: row.acknowledgedByUserId, + acknowledgedByName: row.acknowledgedByName, + acknowledgedVia: + row.acknowledgedVia === null + ? null + : Option.getOrNull(decodeAlertIncidentAckSource(row.acknowledgedVia)), errorIssueId: row.errorIssueId != null ? decodeErrorIssueIdSync(row.errorIssueId) : null, }) @@ -205,8 +217,9 @@ export class AlertReadModelsService extends Context.Service< orgId, ...(options.status !== undefined ? { status: options.status } : undefined), ...(options.ruleId !== undefined ? { ruleId: options.ruleId } : undefined), + ...(options.acknowledged !== undefined ? { acknowledged: options.acknowledged } : undefined), }) - const { status, ruleId } = options + const { status, ruleId, acknowledged } = options const rows = yield* dbExecute((db) => db.run( PG.from(AlertIncidents) @@ -215,6 +228,12 @@ export class AlertReadModelsService extends Context.Service< $.orgId.eq(orgId), status ? $.status.eq(status) : undefined, ruleId ? $.ruleId.eq(ruleId) : undefined, + acknowledged === undefined ? undefined : $.status.eq("open"), + acknowledged === undefined + ? undefined + : acknowledged + ? $.acknowledgedAt.isNotNull() + : $.acknowledgedAt.isNull(), ]) .orderBy(["lastTriggeredAt", "desc"], ["id", "desc"]) .limit(options.limit ?? 100) diff --git a/packages/backend/src/services/alerts/AlertsService.test.ts b/packages/backend/src/services/alerts/AlertsService.test.ts index a39e4e8391..0082f6aa93 100644 --- a/packages/backend/src/services/alerts/AlertsService.test.ts +++ b/packages/backend/src/services/alerts/AlertsService.test.ts @@ -1616,6 +1616,94 @@ describe("AlertsService", () => { }).pipe(Effect.provide(makeLayer(testDb, makeWarehouseStub(state), { fetch: failingFetch }))) }) + it.effect("holds reminders while an incident is acknowledged and clears the ack on resolve", () => { + const testDb = createTestDb(trackedDbs) + const breaching = tracesRow({ + count: 200, + avgDuration: 40, + p50Duration: 20, + p95Duration: 120, + p99Duration: 240, + errorRate: 10, + satisfiedCount: 180, + toleratingCount: 10, + apdexScore: 0.925, + }) + const state = { + tracesAggregateRows: [breaching] as ReadonlyArray>, + } + + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const alerts = yield* AlertsService + const orgId = asOrgId("org_ack_holds_renotify") + const userId = asUserId("user_ack_holds_renotify") + const destination = yield* createWebhookDestination(alerts, orgId, userId) + yield* alerts.createRule( + orgId, + userId, + adminRoles, + new AlertRuleUpsertRequest({ + name: "Checkout error rate", + severity: "critical", + enabled: true, + serviceNames: ["checkout"], + signalType: "error_rate", + comparator: "gt", + threshold: 5, + windowMinutes: 5, + minimumSampleCount: 10, + consecutiveBreachesRequired: 2, + consecutiveHealthyRequired: 2, + renotifyIntervalMinutes: 5, + destinationIds: [destination.id], + }), + ) + + // The trigger opens on the second tick; acknowledge it straight away. + for (let minute = 0; minute < 2; minute += 1) { + yield* alerts.runSchedulerTick() + yield* TestClock.adjust(Duration.minutes(1)) + } + yield* Effect.promise(() => + executeSql( + testDb, + `update alert_incidents set acknowledged_at = now(), acknowledged_by_user_id = $1, + acknowledged_by_name = 'Dana', acknowledged_via = 'web' where org_id = $2`, + [userId, orgId], + ), + ) + + // Well past the 5-minute reminder interval, still breaching. + for (let minute = 0; minute < 12; minute += 1) { + yield* alerts.runSchedulerTick() + yield* TestClock.adjust(Duration.minutes(1)) + } + const events = yield* alerts.listDeliveryEvents(orgId) + assert.deepStrictEqual( + Arr.dedupe(Arr.map(events.events, (event: { eventType: string }) => event.eventType)), + ["trigger"], + ) + + state.tracesAggregateRows = [{ ...breaching, errorRate: 1 }] + for (let minute = 0; minute < 3; minute += 1) { + yield* alerts.runSchedulerTick() + yield* TestClock.adjust(Duration.minutes(1)) + } + const [incident] = (yield* alerts.listIncidents(orgId)).incidents + assert.strictEqual(incident?.status, "resolved") + assert.isNull(incident?.acknowledgedAt) + assert.isNull(incident?.acknowledgedByName) + const afterResolve = yield* alerts.listDeliveryEvents(orgId) + assert.isTrue( + Arr.some( + afterResolve.events, + (event: { eventType: string }) => event.eventType === "resolve", + ), + ) + }).pipe(Effect.provide(makeLayer(testDb, makeWarehouseStub(state), { fetch: okFetch }))) + }) + it.effect("skips unchanged alert_rule_states writes and refreshes on the 5-minute heartbeat", () => { const testDb = createTestDb(trackedDbs) // Healthy from the start: errorRate 1 stays below the threshold of 5. diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index 132b1b55e2..175e3506d4 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -39,6 +39,7 @@ import { AlertEventType as AlertEventTypeSchema, AlertForbiddenError, AlertIncidentDocument, + AlertIncidentEventId, AlertIncidentHoldReason, AlertIncidentStatus, type AlertDestinationNotFoundError, @@ -80,6 +81,7 @@ import { type AlertDeliveryEventRow, AlertDestinations, type AlertDestinationRow, + AlertIncidentEvents, AlertIncidents, type AlertIncidentRow, AlertRuleClaims, @@ -434,6 +436,15 @@ const StoredDeliveryPayloadSchema = Schema.StructWithRest( const decodeAlertRuleIdSync = Schema.decodeUnknownSync(AlertRuleDocument.fields.id) const decodeAlertIncidentIdSync = Schema.decodeUnknownSync(AlertIncidentDocument.fields.id) const decodeAlertDeliveryEventIdSync = Schema.decodeUnknownSync(AlertDeliveryEventDocument.fields.id) +const decodeAlertIncidentEventIdSync = Schema.decodeUnknownSync(AlertIncidentEventId) + +/** An incident that leaves `open` stops being acknowledged; its history stays in `alert_incident_events`. */ +const clearedAcknowledgement = { + acknowledgedAt: null, + acknowledgedByUserId: null, + acknowledgedByName: null, + acknowledgedVia: null, +} as const const decodeIsoDateTimeStringSync = Schema.decodeUnknownSync(AlertDestinationDocument.fields.createdAt) const decodeAlertSeveritySync = Schema.decodeUnknownSync(AlertSeveritySchema) const decodeAlertSignalTypeSync = Schema.decodeUnknownSync(AlertSignalTypeSchema) @@ -3002,6 +3013,7 @@ export class AlertsService extends Context.Service [$.id.eq(openIncident.id), $.status.eq("open")]) .returning("id"), ), ) + if (refreshed.length > 0 && lifecycle.clearAcknowledgement) { + yield* dbExecute((db) => + db.run( + PG.insertInto(AlertIncidentEvents).values({ + id: decodeAlertIncidentEventIdSync(makeUuid()), + orgId: row.orgId, + incidentId: openIncident.id, + type: "ack_cleared", + actorUserId: null, + actorName: null, + source: null, + createdAt: timestamp, + }), + ), + ) + } if (refreshed.length === 0) { return { transition: "none" as const, @@ -3335,6 +3367,7 @@ export class AlertsService extends Context.Service [$.id.eq(openIncident.id), $.status.eq("open")]) @@ -3655,6 +3689,7 @@ export class AlertsService extends Context.Service [$.id.eq(incident.id), $.status.eq("open")]) @@ -3694,6 +3729,7 @@ export class AlertsService extends Context.Service [$.id.eq(incident.id), $.status.eq("open")]) @@ -3878,6 +3915,7 @@ export class AlertsService extends Context.Service = [ DashboardVersions, Dashboards, AlertDeliveryEvents, + // Before its incident: an event row references it. + AlertIncidentEvents, AlertIncidents, AlertRuleStates, AlertRules, diff --git a/packages/db/drizzle/20261105234724_alert_incident_ack/migration.sql b/packages/db/drizzle/20261105234724_alert_incident_ack/migration.sql new file mode 100644 index 0000000000..ab05cbd366 --- /dev/null +++ b/packages/db/drizzle/20261105234724_alert_incident_ack/migration.sql @@ -0,0 +1,30 @@ +-- metadata +CREATE TABLE IF NOT EXISTS "alert_incident_events" ( + "id" text NOT NULL, + "org_id" text NOT NULL, + "incident_id" text NOT NULL, + "type" text NOT NULL, + "actor_user_id" text, + "actor_name" text, + "source" text, + "created_at" timestamp with time zone NOT NULL, + CONSTRAINT "alert_incident_events_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +-- metadata +ALTER TABLE "alert_incidents" ADD COLUMN IF NOT EXISTS "acknowledged_at" timestamp with time zone; +--> statement-breakpoint +-- metadata +ALTER TABLE "alert_incidents" ADD COLUMN IF NOT EXISTS "acknowledged_by_user_id" text; +--> statement-breakpoint +-- metadata +ALTER TABLE "alert_incidents" ADD COLUMN IF NOT EXISTS "acknowledged_by_name" text; +--> statement-breakpoint +-- metadata +ALTER TABLE "alert_incidents" ADD COLUMN IF NOT EXISTS "acknowledged_via" text; +--> statement-breakpoint +-- metadata +CREATE INDEX IF NOT EXISTS "alert_incident_events_incident_idx" ON "alert_incident_events" USING btree ("org_id", "incident_id", "created_at"); +--> statement-breakpoint +-- metadata +ALTER TABLE "alert_incident_events" ADD CONSTRAINT "alert_incident_events_incident_id_alert_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "alert_incidents" ("id") ON DELETE CASCADE; diff --git a/packages/db/drizzle/20261105234724_alert_incident_ack/snapshot.json b/packages/db/drizzle/20261105234724_alert_incident_ack/snapshot.json new file mode 100644 index 0000000000..654a895f1c --- /dev/null +++ b/packages/db/drizzle/20261105234724_alert_incident_ack/snapshot.json @@ -0,0 +1,18980 @@ +{ + "version": "1", + "dialect": "postgres", + "id": "735b86449ab62a2a8b8163dbb4278bca8e991f0dfafc0f3b7c2418ea6b2446b9", + "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_incident_events", + "primaryKey": { + "name": "alert_incident_events_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": "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": "payload_json", + "position": 18, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "created_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "updated_at", + "position": 20, + "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_incident_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "incident_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "actor_user_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "actor_name", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "source", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "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": "acknowledged_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "acknowledged_by_user_id", + "position": 25, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "acknowledged_by_name", + "position": 26, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "acknowledged_via", + "position": 27, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "error_issue_id", + "position": 28, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "created_at", + "position": 29, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "updated_at", + "position": 30, + "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": "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_incident_events", + "name": "alert_incident_events_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_id\"", + "\"created_at\"" + ], + "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": "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_incident_events", + "name": "alert_incident_events_incident_id_alert_incidents_id_fk", + "columns": [ + "incident_id" + ], + "foreignTable": "alert_incidents", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "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..e0b2a069c6 100644 --- a/packages/db/src/tables/alerts.ts +++ b/packages/db/src/tables/alerts.ts @@ -2,10 +2,12 @@ import * as PG from "@maple-dev/effect-orm/postgres" import { AlertDeliveryEventId, AlertDestinationId, + AlertIncidentEventId, AlertIncidentId, AlertRuleId, ErrorIssueId, OrgId, + UserId, } from "@maple/domain/primitives" import { Schema } from "effect" @@ -212,6 +214,15 @@ const alertIncidentsColumns = { // flowing. Both are null once the incident is firing or resolved. holdReason: PG.column(PG.nullable(PG.text), { name: "hold_reason" }), heldSince: PG.column(PG.nullable(PG.timestamptzMillis), { name: "held_since" }), + // Set while an open incident is acknowledged ("someone is on it"): the + // scheduler sends no reminders until it is unacknowledged, escalates to a + // higher severity, or resolves. All four are null otherwise. + acknowledgedAt: PG.column(PG.nullable(PG.timestamptzMillis), { name: "acknowledged_at" }), + acknowledgedByUserId: PG.column(PG.nullable(PG.brand(PG.text, UserId)), { + name: "acknowledged_by_user_id", + }), + acknowledgedByName: PG.column(PG.nullable(PG.text), { name: "acknowledged_by_name" }), + acknowledgedVia: PG.column(PG.nullable(PG.text), { name: "acknowledged_via" }), // 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" }), @@ -245,6 +256,36 @@ export const AlertIncidents = PG.table("alert_incidents", { tenantColumn: "orgId", }) +/** + * Append-only activity on an alert incident: acknowledgements, unacknowledgements and + * the scheduler clearing an ack on escalation. The incident row holds the current ack; + * this holds its history, and is what on-call escalation reads to stop escalating. + */ +export const AlertIncidentEvents = PG.table("alert_incident_events", { + columns: { + id: PG.brand(PG.text, AlertIncidentEventId), + orgId: PG.column(PG.brand(PG.text, OrgId), { name: "org_id" }), + incidentId: PG.column(PG.brand(PG.text, AlertIncidentId), { name: "incident_id" }), + type: PG.text, + actorUserId: PG.column(PG.nullable(PG.brand(PG.text, UserId)), { name: "actor_user_id" }), + actorName: PG.column(PG.nullable(PG.text), { name: "actor_name" }), + /** Null for scheduler-written events. */ + source: PG.nullable(PG.text), + createdAt: PG.column(PG.timestamptzMillis, { name: "created_at" }), + }, + primaryKey: ["id"], + indexes: [PG.index("alert_incident_events_incident_idx", ["orgId", "incidentId", "createdAt"])], + foreignKeys: [ + PG.foreignKey({ + columns: ["incidentId"], + references: AlertIncidents, + foreignColumns: ["id"], + onDelete: "cascade", + }), + ], + tenantColumn: "orgId", +}) + export const AlertDeliveryEvents = PG.table("alert_delivery_events", { columns: { id: PG.brand(PG.text, AlertDeliveryEventId), @@ -324,4 +365,5 @@ export type AlertRuleRow = PG.SelectRowOf export type AlertRuleStateRow = PG.SelectRowOf export type AlertIncidentRow = PG.SelectRowOf export type AlertDeliveryEventRow = PG.SelectRowOf +export type AlertIncidentEventRow = PG.SelectRowOf export type AlertLinearIssueRow = PG.SelectRowOf diff --git a/packages/db/src/tables/index.ts b/packages/db/src/tables/index.ts index ee1962ff46..ac85205803 100644 --- a/packages/db/src/tables/index.ts +++ b/packages/db/src/tables/index.ts @@ -7,6 +7,7 @@ import { AlertRuleClaims, AlertRuleStates, AlertIncidents, + AlertIncidentEvents, AlertDeliveryEvents, AlertLinearIssues, } from "./alerts" @@ -162,6 +163,7 @@ export const allTables: ReadonlyArray> = [ AlertRuleClaims, AlertRuleStates, AlertIncidents, + AlertIncidentEvents, AlertDeliveryEvents, AlertLinearIssues, AnomalyDetectorSettings, diff --git a/packages/domain/src/http/alerts.ts b/packages/domain/src/http/alerts.ts index 419fb3f8b0..57af9712db 100644 --- a/packages/domain/src/http/alerts.ts +++ b/packages/domain/src/http/alerts.ts @@ -3,6 +3,7 @@ import { QueryEngineAlertReducer, QueryEngineNoDataBehavior } from "../query-eng import { AlertDeliveryEventId, AlertDestinationId, + AlertIncidentEventId, AlertIncidentId, AlertRuleId, ChatConnectorId, @@ -127,6 +128,27 @@ export const AlertIncidentHoldReason = Schema.Literals([ }) export type AlertIncidentHoldReason = Schema.Schema.Type +/** Where an acknowledgement came from. */ +export const AlertIncidentAckSource = Schema.Literals(["web", "api", "mcp", "slack", "discord"]).annotate({ + identifier: "@maple/AlertIncidentAckSource", + title: "Alert Incident Acknowledgement Source", +}) +export type AlertIncidentAckSource = Schema.Schema.Type + +/** + * The incident activity log. `ack_cleared` is written by the scheduler when an + * acknowledged incident escalates to a higher severity and pages again. + */ +export const AlertIncidentEventType = Schema.Literals([ + "acknowledged", + "unacknowledged", + "ack_cleared", +]).annotate({ + identifier: "@maple/AlertIncidentEventType", + title: "Alert Incident Event Type", +}) +export type AlertIncidentEventType = Schema.Schema.Type + export const AlertEventType = Schema.Literals(["trigger", "resolve", "renotify", "test"]).annotate({ identifier: "@maple/AlertEventType", title: "Alert Event Type", @@ -875,9 +897,32 @@ export class AlertIncidentDocument extends Schema.Class(" /** Set while the incident is open but waiting on telemetry; see {@link AlertIncidentHoldReason}. */ holdReason: Schema.NullOr(AlertIncidentHoldReason), heldSince: Schema.NullOr(IsoDateTimeString), + /** + * Set while an open incident is acknowledged: reminders pause until it is + * unacknowledged, escalates or resolves. All null otherwise. + */ + acknowledgedAt: Schema.NullOr(IsoDateTimeString), + /** The Maple user who acknowledged; null for an unlinked chat account. */ + acknowledgedByUserId: Schema.NullOr(UserId), + /** Display name of whoever acknowledged, as the source reported it. */ + acknowledgedByName: Schema.NullOr(Schema.String), + acknowledgedVia: Schema.NullOr(AlertIncidentAckSource), errorIssueId: Schema.NullOr(ErrorIssueId), }) {} +export class AlertIncidentEventDocument extends Schema.Class( + "AlertIncidentEventDocument", +)({ + id: AlertIncidentEventId, + incidentId: AlertIncidentId, + type: AlertIncidentEventType, + actorUserId: Schema.NullOr(UserId), + actorName: Schema.NullOr(Schema.String), + /** Null for scheduler-written events. */ + source: Schema.NullOr(AlertIncidentAckSource), + createdAt: IsoDateTimeString, +}) {} + export class AlertIncidentsListResponse extends Schema.Class( "AlertIncidentsListResponse", )({ @@ -1165,6 +1210,20 @@ export class AlertIncidentNotFoundError extends HttpTaggedError()( + "@maple/http/errors/AlertIncidentResolvedError", + { message: Schema.String, incidentId: AlertIncidentId }, + { + status: 409, + code: "alert_incident_resolved", + title: "Alert incident already resolved", + retry: "never", + recovery: "none", + exposure: "public_message", + }, +) {} + export type AlertNotFoundError = | AlertRuleNotFoundError | AlertDestinationNotFoundError diff --git a/packages/domain/src/http/v2/alert-incidents.ts b/packages/domain/src/http/v2/alert-incidents.ts index cb6c880c35..5dd1e7e177 100644 --- a/packages/domain/src/http/v2/alert-incidents.ts +++ b/packages/domain/src/http/v2/alert-incidents.ts @@ -1,9 +1,11 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api" import { Schema } from "effect" -import { AlertIncidentNotFoundError, AlertPersistenceError } from "../alerts" +import { AlertIncidentNotFoundError, AlertIncidentResolvedError, AlertPersistenceError } from "../alerts" import { AlertComparator, AlertEventType, + AlertIncidentAckSource, + AlertIncidentEventType, AlertIncidentHoldReason, AlertIncidentStatus, AlertSeverity, @@ -13,7 +15,13 @@ import { AuthorizationV2 } from "./auth" import { wireExample, ListOf, ListQuery, Timestamp } from "./envelopes" import { V2ParameterInvalid } from "./errors" import { publicError } from "./public-error" -import { AlertIncidentPublicId, AlertRulePublicId, ErrorIssuePublicId } from "./resource-ids" +import { UserId } from "../../primitives" +import { + AlertIncidentEventPublicId, + AlertIncidentPublicId, + AlertRulePublicId, + ErrorIssuePublicId, +} from "./resource-ids" const alertIncidentExample = { id: "inc_tC4d9V79DCDzgbGKhAnff9", @@ -38,9 +46,22 @@ const alertIncidentExample = { last_notified_at: "2026-07-15T09:10:05.000Z", hold_reason: null, held_since: null, + acknowledged_at: "2026-07-15T09:14:02.000Z", + acknowledged_by: { user_id: "user_2Nk8mXqPfR3yZ1aB4cD5eF6g", name: "Dana Kim" }, + acknowledged_via: "slack", error_issue_id: null, } as const +const V2AlertIncidentActor = Schema.Struct({ + user_id: Schema.NullOr(UserId).annotate({ + description: "The Maple user, or `null` for a chat account not linked to one.", + }), + name: Schema.NullOr(Schema.String).annotate({ + description: "Display name as the source reported it, or `null` when none was available.", + examples: ["Dana Kim"], + }), +}).annotate({ identifier: "AlertIncidentActor", title: "Alert incident actor" }) + // v2 wire schemas are annotated `Schema.Struct`s (not `Schema.Class`) — see the // note in api-keys.ts. export const V2AlertIncident = Schema.Struct({ @@ -113,6 +134,18 @@ export const V2AlertIncident = Schema.Struct({ held_since: Schema.NullOr(Timestamp).annotate({ description: "When the current hold began, or `null` when the incident is not held.", }), + acknowledged_at: Schema.NullOr(Timestamp).annotate({ + description: + "When someone acknowledged this open incident, or `null`. While acknowledged, reminders (`renotify`) pause; an escalation to a higher severity still notifies and clears the acknowledgement. Cleared when the incident resolves.", + }), + acknowledged_by: Schema.NullOr(V2AlertIncidentActor).annotate({ + description: "Who acknowledged the incident, or `null` when it is not acknowledged.", + }), + acknowledged_via: Schema.NullOr(AlertIncidentAckSource).annotate({ + description: + "Where the acknowledgement came from (`web`, `api`, `mcp`, `slack`, `discord`), or `null` when not acknowledged.", + examples: ["slack"], + }), error_issue_id: Schema.NullOr(ErrorIssuePublicId).annotate({ description: "The linked error issue (`iss_…`) when the incident was correlated to one, or `null`.", }), @@ -120,11 +153,46 @@ export const V2AlertIncident = Schema.Struct({ identifier: "AlertIncident", title: "Alert Incident", description: - "A period during which an alert rule's condition held: opened after enough consecutive breaches, resolved by the scheduler after enough consecutive healthy checks. Read-only via the API.", + "A period during which an alert rule's condition held: opened after enough consecutive breaches, resolved by the scheduler after enough consecutive healthy checks. An open incident can be acknowledged to pause reminders; resolution is scheduler-driven.", examples: [wireExample(alertIncidentExample)], }) export type V2AlertIncident = Schema.Schema.Type +export const V2AlertIncidentEvent = Schema.Struct({ + id: AlertIncidentEventPublicId, + object: Schema.Literal("alert_incident_event").annotate({ + description: 'The object type — always `"alert_incident_event"`.', + examples: ["alert_incident_event"], + }), + incident_id: AlertIncidentPublicId, + type: AlertIncidentEventType.annotate({ + description: + "`acknowledged` or `unacknowledged` by a person, or `ack_cleared` when the scheduler cleared an acknowledgement because the incident escalated to a higher severity.", + examples: ["acknowledged"], + }), + actor: Schema.NullOr(V2AlertIncidentActor).annotate({ + description: "Who did it, or `null` for a scheduler-written event.", + }), + source: Schema.NullOr(AlertIncidentAckSource).annotate({ + description: "Where it came from, or `null` for a scheduler-written event.", + }), + created_at: Timestamp, +}).annotate({ + identifier: "AlertIncidentEvent", + title: "Alert Incident Event", + description: "One entry in an alert incident's activity log, oldest first.", +}) +export type V2AlertIncidentEvent = Schema.Schema.Type + +const AlertIncidentEventList = Schema.Struct({ + object: Schema.Literal("list"), + data: Schema.Array(V2AlertIncidentEvent), +}).annotate({ + identifier: "AlertIncidentEventList", + title: "Alert incident event list", + description: "An incident's activity, oldest first.", +}) + const IncidentsQuery = Schema.Struct({ ...ListQuery.fields, status: Schema.optional( @@ -138,6 +206,13 @@ const IncidentsQuery = Schema.Struct({ description: "Only return incidents opened by this alert rule (`alrt_…`).", }), ), + acknowledged: Schema.optional( + Schema.Literals(["true", "false"]).annotate({ + description: + "`true` for open incidents someone has acknowledged, `false` for open incidents nobody has. Implies `status=open`.", + examples: ["false"], + }), + ), }).annotate({ identifier: "AlertIncidentListQuery", title: "Alert incident list query", @@ -145,6 +220,8 @@ const IncidentsQuery = Schema.Struct({ }) const alertPersistence = publicError(AlertPersistenceError) +const alertIncidentNotFound = publicError(AlertIncidentNotFoundError) +const alertIncidentResolved = publicError(AlertIncidentResolvedError) const AlertIncidentList = ListOf(V2AlertIncident).annotate({ identifier: "AlertIncidentList", @@ -171,7 +248,7 @@ export class V2AlertIncidentsApiGroup extends HttpApiGroup.make("alertIncidents" HttpApiEndpoint.get("retrieve", "/:id", { params: { id: AlertIncidentPublicId }, success: V2AlertIncident, - error: [publicError(AlertIncidentNotFoundError), alertPersistence], + error: [alertIncidentNotFound, alertPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "getAlertIncident", @@ -181,12 +258,54 @@ export class V2AlertIncidentsApiGroup extends HttpApiGroup.make("alertIncidents" }), ), ) + .add( + HttpApiEndpoint.post("acknowledge", "/:id/acknowledge", { + params: { id: AlertIncidentPublicId }, + success: V2AlertIncident, + error: [alertIncidentNotFound, alertIncidentResolved, alertPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "acknowledgeAlertIncident", + summary: "Acknowledge an alert incident", + description: + "Marks an open incident as acknowledged by the calling user, which pauses reminders until it is unacknowledged, escalates to a higher severity, or resolves. Acknowledging an already acknowledged incident changes nothing and returns it. Fails with 409 on a resolved incident. Requires the `alerts:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.post("unacknowledge", "/:id/unacknowledge", { + params: { id: AlertIncidentPublicId }, + success: V2AlertIncident, + error: [alertIncidentNotFound, alertIncidentResolved, alertPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "unacknowledgeAlertIncident", + summary: "Unacknowledge an alert incident", + description: + "Clears an open incident's acknowledgement, so reminders resume on their usual schedule. Fails with 409 on a resolved incident. Requires the `alerts:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.get("listEvents", "/:id/events", { + params: { id: AlertIncidentPublicId }, + success: AlertIncidentEventList, + error: [alertIncidentNotFound, alertPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "listAlertIncidentEvents", + summary: "List an alert incident's events", + description: + "Returns the incident's acknowledgement history, oldest first. Requires the `alerts:read` scope.", + }), + ), + ) .prefix("/v2/alerts/incidents") .middleware(AuthorizationV2) .annotateMerge( OpenApi.annotations({ title: "Alert Incidents", 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.", + "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. An open incident can be acknowledged to pause its reminders.", }), ) {} diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index eec28a2b0d..ff2669f0ae 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -111,6 +111,7 @@ describe("MapleApiV2 OpenAPI", () => { "GET /v2/alerts/destinations/{id}", "GET /v2/alerts/incidents", "GET /v2/alerts/incidents/{id}", + "GET /v2/alerts/incidents/{id}/events", "GET /v2/alerts/rules", "GET /v2/alerts/rules/{id}", "GET /v2/alerts/rules/{id}/checks", @@ -213,6 +214,8 @@ describe("MapleApiV2 OpenAPI", () => { "POST /v2/alerts/destinations", "POST /v2/alerts/destinations/telegram/chats", "POST /v2/alerts/destinations/{id}/test", + "POST /v2/alerts/incidents/{id}/acknowledge", + "POST /v2/alerts/incidents/{id}/unacknowledge", "POST /v2/alerts/rules", "POST /v2/alerts/rules/preview", "POST /v2/alerts/rules/test", diff --git a/packages/domain/src/http/v2/resource-ids.ts b/packages/domain/src/http/v2/resource-ids.ts index f8b2afe7da..ef5aa99576 100644 --- a/packages/domain/src/http/v2/resource-ids.ts +++ b/packages/domain/src/http/v2/resource-ids.ts @@ -3,6 +3,7 @@ import { AgentFeedbackId, AlertDeliveryEventId, AlertDestinationId, + AlertIncidentEventId, AlertIncidentId, AlertRuleId, AnomalyIncidentId, @@ -20,6 +21,7 @@ export const AlertRulePublicId = PublicId(PublicIdPrefixes.alertRule, AlertRuleI export const AlertDestinationPublicId = PublicId(PublicIdPrefixes.alertDestination, AlertDestinationId) export const AlertIncidentPublicId = PublicId(PublicIdPrefixes.alertIncident, AlertIncidentId) export const AlertDeliveryEventPublicId = PublicId(PublicIdPrefixes.event, AlertDeliveryEventId) +export const AlertIncidentEventPublicId = PublicId(PublicIdPrefixes.event, AlertIncidentEventId) export const ActorPublicId = PublicId(PublicIdPrefixes.actor, ActorId) export const AnomalyIncidentPublicId = PublicId(PublicIdPrefixes.anomalyIncident, AnomalyIncidentId) export const ErrorIncidentPublicId = PublicId(PublicIdPrefixes.errorIncident, ErrorIncidentId) diff --git a/packages/domain/src/http/v2/v2-contract.test.ts b/packages/domain/src/http/v2/v2-contract.test.ts index 14c121983b..c7c4866226 100644 --- a/packages/domain/src/http/v2/v2-contract.test.ts +++ b/packages/domain/src/http/v2/v2-contract.test.ts @@ -478,6 +478,9 @@ describe("V2 alerts wire format", () => { last_notified_at: null, hold_reason: null, held_since: null, + acknowledged_at: null, + acknowledged_by: null, + acknowledged_via: null, error_issue_id: null, }) expect(incident.id).toBe(INCIDENT_UUID) diff --git a/packages/domain/src/mcp-outputs/alerts.ts b/packages/domain/src/mcp-outputs/alerts.ts index 438bc6ebe6..884043578e 100644 --- a/packages/domain/src/mcp-outputs/alerts.ts +++ b/packages/domain/src/mcp-outputs/alerts.ts @@ -216,6 +216,9 @@ export const AlertIncidentRow = Schema.Struct({ /** Set while an open incident is waiting on telemetry rather than observing a breach. */ holdReason: NullableString, heldSince: NullableString, + /** Set while an open incident is acknowledged; reminders pause until it is not. */ + acknowledgedAt: Schema.optionalKey(NullableString), + acknowledgedBy: Schema.optionalKey(NullableString), threshold: Schema.Number, comparator: Schema.String, firstTriggeredAt: Schema.String, @@ -298,6 +301,19 @@ export const IncidentTimelineRow = Schema.Struct({ lastTriggeredAt: Schema.String, resolvedAt: NullableString, lastNotifiedAt: NullableString, + acknowledgedAt: Schema.optionalKey(NullableString), + acknowledgedBy: Schema.optionalKey(NullableString), +}) + +export const AcknowledgeAlertIncidentOutput = Schema.Struct({ + id: Schema.String, + ruleName: Schema.String, + status: Schema.String, + acknowledged: Schema.Boolean, + acknowledgedAt: NullableString, + acknowledgedBy: NullableString, + /** False when the incident was already in the requested state. */ + changed: Schema.Boolean, }) 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..e279057cef 100644 --- a/packages/domain/src/mcp-outputs/catalog.ts +++ b/packages/domain/src/mcp-outputs/catalog.ts @@ -34,6 +34,7 @@ import { } from "./integrations" import { CiRunsOutput, CompareCommitsOutput } from "./ci" import { + AcknowledgeAlertIncidentOutput, CreateAlertRuleOutput, DeleteAlertRuleOutput, GetAlertRuleOutput, @@ -148,6 +149,7 @@ export const McpToolOutputs = { delete_ingest_rule: DeleteIngestRuleOutput, delete_scrape_target: DeleteScrapeTargetOutput, delete_alert_rule: DeleteAlertRuleOutput, + acknowledge_alert_incident: AcknowledgeAlertIncidentOutput, delete_uptime_monitor: DeleteUptimeMonitorOutput, describe_dashboard_schema: DescribeDashboardSchemaOutput, describe_warehouse_tables: DescribeWarehouseTablesOutput, diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index e03ddb1d5a..d066156105 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 AlertIncidentEventId = MapleUuidId("@maple/AlertIncidentEventId", "Alert Incident Event ID") +export type AlertIncidentEventId = 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/headroom.test.ts b/packages/web-data/src/alerts/headroom.test.ts index 4ce6530efb..8840efcf9e 100644 --- a/packages/web-data/src/alerts/headroom.test.ts +++ b/packages/web-data/src/alerts/headroom.test.ts @@ -129,6 +129,10 @@ describe("quietForMs", () => { lastNotifiedAt: null, holdReason: null, heldSince: null, + acknowledgedAt: null, + acknowledgedByUserId: null, + acknowledgedByName: null, + acknowledgedVia: null, errorIssueId: null, }) diff --git a/packages/web-data/src/alerts/incident-timeline.test.ts b/packages/web-data/src/alerts/incident-timeline.test.ts index a8f47bbf17..4ae08e547f 100644 --- a/packages/web-data/src/alerts/incident-timeline.test.ts +++ b/packages/web-data/src/alerts/incident-timeline.test.ts @@ -32,6 +32,10 @@ function makeIncident(overrides: Record = {}): AlertIncidentDoc lastNotifiedAt: "2026-10-15T14:14:05.000Z", holdReason: null, heldSince: null, + acknowledgedAt: null, + acknowledgedByUserId: null, + acknowledgedByName: null, + acknowledgedVia: null, errorIssueId: 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..d86f9b1334 100644 --- a/packages/web-data/src/alerts/overview-derive.test.ts +++ b/packages/web-data/src/alerts/overview-derive.test.ts @@ -96,6 +96,10 @@ function makeIncident(overrides: Record = {}): AlertIncidentDoc lastNotifiedAt: null, holdReason: null, heldSince: null, + acknowledgedAt: null, + acknowledgedByUserId: null, + acknowledgedByName: null, + acknowledgedVia: null, errorIssueId: null, ...overrides, }) diff --git a/packages/web-data/src/alerts/overview.test.ts b/packages/web-data/src/alerts/overview.test.ts index a06be184cd..b6c186c0ed 100644 --- a/packages/web-data/src/alerts/overview.test.ts +++ b/packages/web-data/src/alerts/overview.test.ts @@ -84,6 +84,10 @@ function makeIncident(overrides: Record = {}): AlertIncidentDoc lastNotifiedAt: null, holdReason: null, heldSince: null, + acknowledgedAt: null, + acknowledgedByUserId: null, + acknowledgedByName: null, + acknowledgedVia: null, errorIssueId: 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..654275dd64 100644 --- a/packages/web-data/src/alerts/preview-insights.test.ts +++ b/packages/web-data/src/alerts/preview-insights.test.ts @@ -75,6 +75,10 @@ function incident(groupKey: string | null, status = "open"): AlertIncidentDocume lastNotifiedAt: at(0), holdReason: null, heldSince: null, + acknowledgedAt: null, + acknowledgedByUserId: null, + acknowledgedByName: null, + acknowledgedVia: null, errorIssueId: null, }) } diff --git a/packages/web-data/src/collections/alerts.ts b/packages/web-data/src/collections/alerts.ts index b8f01102ec..68314ed8e7 100644 --- a/packages/web-data/src/collections/alerts.ts +++ b/packages/web-data/src/collections/alerts.ts @@ -8,6 +8,7 @@ import { AlertEventType, AlertGroupBy, AlertIncidentDocument, + AlertIncidentAckSource, AlertIncidentHoldReason, AlertIncidentStatus, AlertNotificationTemplate, @@ -45,6 +46,9 @@ const asEvaluationInterval = Schema.decodeUnknownSync(Schema.NullOr(AlertEvaluat const decodeErrorKind = Schema.decodeUnknownOption(AlertEvaluationErrorKind) const asIncidentStatus = Schema.decodeUnknownSync(AlertIncidentStatus) const asHoldReason = Schema.decodeUnknownSync(AlertIncidentHoldReason) +// Lenient: a source added by a newer server reads as unknown rather than breaking the alerts list. +const decodeAckSource = Schema.decodeUnknownOption(AlertIncidentAckSource) +const decodeUserId = Schema.decodeUnknownOption(UserId) const asEventType = Schema.decodeUnknownSync(AlertEventType) const asReducer = Schema.decodeUnknownSync(QueryEngineAlertReducer) // Lenient: a behavior added by a newer server reads as skip rather than breaking the alerts list. @@ -164,6 +168,10 @@ export const AlertIncidentRowSchema = Schema.Struct({ last_notified_at: Schema.NullOr(Schema.String), hold_reason: Schema.NullOr(Schema.String), held_since: Schema.NullOr(Schema.String), + acknowledged_at: Schema.optionalKey(Schema.NullOr(Schema.String)), + acknowledged_by_user_id: Schema.optionalKey(Schema.NullOr(Schema.String)), + acknowledged_by_name: Schema.optionalKey(Schema.NullOr(Schema.String)), + acknowledged_via: Schema.optionalKey(Schema.NullOr(Schema.String)), error_issue_id: Schema.NullOr(Schema.String), created_at: Schema.String, updated_at: Schema.String, @@ -293,6 +301,14 @@ export const rowToAlertIncidentDocument = (row: AlertIncidentRow): AlertIncident lastNotifiedAt: row.last_notified_at != null ? decodeIso(row.last_notified_at) : null, holdReason: row.hold_reason != null ? asHoldReason(row.hold_reason) : null, heldSince: row.held_since != null ? decodeIso(row.held_since) : null, + acknowledgedAt: row.acknowledged_at != null ? decodeIso(row.acknowledged_at) : null, + acknowledgedByUserId: + row.acknowledged_by_user_id != null + ? Option.getOrNull(decodeUserId(row.acknowledged_by_user_id)) + : null, + acknowledgedByName: row.acknowledged_by_name ?? null, + acknowledgedVia: + row.acknowledged_via != null ? Option.getOrNull(decodeAckSource(row.acknowledged_via)) : null, errorIssueId: row.error_issue_id != null ? asErrorIssueId(row.error_issue_id) : null, }) From 5a66d2bda5e1d271d2adb90f80a2a5b85216f449 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 01:27:39 +0100 Subject: [PATCH 02/98] fix(alerts): clear an ack on escalation in the claimed write, decided in SQL The escalation cleared the ack from the tick's prefetch and wrote ack_cleared after the claimed update, outside it. A failed event insert could then lose the escalation page, and a human ack or unack during the evaluation got the wrong outcome. Every escalation now clears whatever ack stands when it commits, with a guarded update in the same claimed write, and writes ack_cleared only when one was cleared. The planner no longer carries the flag. Also: an API-key ack records no user (the key is not a person), the unacknowledge docs say an overdue reminder goes out on the next check, and the MCP incident list takes an acknowledged filter. --- apps/ai/src/mcp/tool-descriptors.generated.ts | 6 +- .../mcp/tools/acknowledge-alert-incident.ts | 9 +- apps/ai/src/mcp/tools/list-alert-incidents.ts | 11 ++ .../api/src/routes/v2/alert-incidents.http.ts | 6 +- apps/api/src/routes/v2/alerts.http.test.ts | 6 +- packages/alerting-core/src/index.ts | 11 +- packages/alerting-core/src/tiers.test.ts | 6 +- .../src/services/alerts/AlertsService.test.ts | 82 ++++++++++++++ .../src/services/alerts/AlertsService.ts | 102 ++++++++++-------- .../domain/src/http/v2/alert-incidents.ts | 4 +- packages/domain/src/mcp-outputs/alerts.ts | 2 + 11 files changed, 174 insertions(+), 71 deletions(-) diff --git a/apps/ai/src/mcp/tool-descriptors.generated.ts b/apps/ai/src/mcp/tool-descriptors.generated.ts index 11e322ff5d..2a5b595092 100644 --- a/apps/ai/src/mcp/tool-descriptors.generated.ts +++ b/apps/ai/src/mcp/tool-descriptors.generated.ts @@ -30,14 +30,14 @@ export const MCP_TOOL_DESCRIPTORS: ReadonlyArray = [ {"name":"list_alert_rules","title":"List Alert Rules","description":"List configured alert rules with severity, signal type and condition. Use list_alert_incidents for what has fired.","inputSchema":{"type":"object","properties":{"services":{"type":"array","items":{"type":"string"},"description":"Only rules covering any of these services: scoped to them, scoped to every service, or raw_query rules whose SQL or name mentions them"},"signal_type":{"type":"string","enum":["error_rate","p95_latency","p99_latency","apdex","throughput","builder_query","raw_query"],"description":"Only rules on this signal type"},"severity":{"type":"string","enum":["warning","critical"],"description":"Only rules with this severity"},"enabled_only":{"type":"boolean","description":"Only enabled rules"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"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}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"services":{"type":"array","items":{"type":"string"}},"signalType":{"type":"string"},"severity":{"type":"string"},"enabledOnly":{"type":"boolean"}},"required":["rules","total"],"additionalProperties":true},"annotations":{"title":"List Alert Rules","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"list_alert_destinations","title":"List Alert Destinations","description":"List the notification destinations a rule can deliver to, with id, type and delivery health. Pass the ids as destination_ids to create_alert_rule / update_alert_rule.","inputSchema":{"type":"object","properties":{"type":{"type":"string","enum":["pagerduty","webhook","discord","telegram","google-chat","email","chat","linear"],"description":"Only destinations of this type"},"enabled_only":{"type":"boolean","description":"Only enabled destinations"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"destinations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"type":{"type":"string"},"enabled":{"type":"boolean"},"summary":{"type":"string"},"channelLabel":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastTestedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastTestError":{"anyOf":[{"type":"string"},{"type":"null"}]},"disabledReason":{"anyOf":[{"type":"string"},{"type":"null"}]},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","type","enabled","summary","channelLabel","lastTestedAt","lastTestError","disabledReason","createdAt","updatedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"type":{"type":"string"},"enabledOnly":{"type":"boolean"}},"required":["destinations","total"],"additionalProperties":true},"annotations":{"title":"List Alert Destinations","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"get_alert_rule","title":"Get Alert Rule","description":"Get full configuration details of a specific alert rule including thresholds, service filters, evaluation settings, and notification destinations. Use list_alert_rules to find rule IDs.","inputSchema":{"type":"object","properties":{"rule_id":{"type":"string","description":"Alert rule ID: its UUID or its REST id (alrt_\u2026)"}},"required":["rule_id"],"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"},"excludeServiceNames":{"type":"array","items":{"type":"string"}},"groupBy":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]},"minimumSampleCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"noDataBehavior":{"type":"string"},"consecutiveBreachesRequired":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"consecutiveHealthyRequired":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"renotifyIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"apdexThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"queryBuilderDraft":{"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},{"type":"null"}]},"rawQuerySql":{"anyOf":[{"type":"string"},{"type":"null"}]},"rawQueryReducer":{"anyOf":[{"type":"string"},{"type":"null"}]},"thresholdUpper":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"notificationTitle":{"anyOf":[{"type":"string"},{"type":"null"}]},"notificationBody":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastEvaluationError":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastEvaluationErrorKind":{"anyOf":[{"type":"string"},{"type":"null"}]},"slowedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastEvaluatedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"destinations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]},"enabled":{"type":"boolean"}},"required":["id","name","type","enabled"],"additionalProperties":true}}},"required":["id","name","enabled","severity","serviceNames","environments","signalType","comparator","threshold","windowMinutes","evaluationIntervalMinutes","slowedIntervalMinutes","destinationIds","createdAt","updatedAt","excludeServiceNames","groupBy","minimumSampleCount","noDataBehavior","consecutiveBreachesRequired","consecutiveHealthyRequired","renotifyIntervalMinutes","apdexThresholdMs","queryBuilderDraft","rawQuerySql","rawQueryReducer"],"additionalProperties":true}},"required":["rule"],"additionalProperties":true},"annotations":{"title":"Get Alert Rule","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, - {"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"}]},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"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_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"},"acknowledged":{"type":"boolean","description":"true: open incidents someone acknowledged; false: open incidents nobody has. Implies status=open"},"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"}]},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"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"},"acknowledged":{"type":"boolean"},"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"}]},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"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"}]},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"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"},"acknowledged":{"type":"boolean"},"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":"acknowledge_alert_incident","title":"Acknowledge Alert Incident","description":"Mark an open alert incident as acknowledged (someone is on it) or, with acknowledged=false, clear the acknowledgement. While acknowledged, reminders pause; an escalation to a higher severity still notifies and clears it. Only open incidents; ids from list_alert_incidents.","inputSchema":{"type":"object","properties":{"incident_id":{"type":"string","description":"Alert incident ID: its UUID or its REST id (inc_\u2026)"},"acknowledged":{"type":"boolean","description":"false to unacknowledge. Default true."}},"required":["incident_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"ruleName":{"type":"string"},"status":{"type":"string"},"acknowledged":{"type":"boolean"},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"anyOf":[{"type":"string"},{"type":"null"}]},"changed":{"type":"boolean"}},"required":["id","ruleName","status","acknowledged","acknowledgedAt","acknowledgedBy","changed"],"additionalProperties":true},"annotations":{"title":"Acknowledge Alert Incident","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"acknowledge_alert_incident","title":"Acknowledge Alert Incident","description":"Mark an open alert incident as acknowledged (someone is on it) or, with acknowledged=false, clear the acknowledgement. While acknowledged, reminders pause; an escalation to a higher severity still notifies and clears it. Only open incidents; ids from list_alert_incidents.","inputSchema":{"type":"object","properties":{"incident_id":{"type":"string","description":"Alert incident ID: its UUID or its REST id (inc_\u2026)"},"acknowledged":{"type":"boolean","description":"false to unacknowledge. Default true."}},"required":["incident_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"ruleId":{"type":"string"},"ruleName":{"type":"string"},"status":{"type":"string"},"acknowledged":{"type":"boolean"},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"anyOf":[{"type":"string"},{"type":"null"}]},"changed":{"type":"boolean"}},"required":["id","ruleId","ruleName","status","acknowledged","acknowledgedAt","acknowledgedBy","changed"],"additionalProperties":true},"annotations":{"title":"Acknowledge Alert Incident","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/acknowledge-alert-incident.ts b/apps/ai/src/mcp/tools/acknowledge-alert-incident.ts index 3491456709..8b4a07bb3a 100644 --- a/apps/ai/src/mcp/tools/acknowledge-alert-incident.ts +++ b/apps/ai/src/mcp/tools/acknowledge-alert-incident.ts @@ -60,6 +60,7 @@ export function registerAcknowledgeAlertIncidentTool(server: McpToolRegistrar) { const { incident } = result return { id: incident.id, + ruleId: incident.ruleId, ruleName: incident.ruleName, status: incident.status, acknowledged: incident.acknowledgedAt !== null, @@ -86,7 +87,13 @@ export function registerAcknowledgeAlertIncidentTool(server: McpToolRegistrar) { ["Acknowledged at", output.acknowledgedAt ?? undefined], ]), ], - next: [doc.next("get_incident_timeline", {}, "the incident timeline")], + next: [ + doc.next( + "get_incident_timeline", + { rule_id: output.ruleId }, + "this rule's incident timeline", + ), + ], }), }) } diff --git a/apps/ai/src/mcp/tools/list-alert-incidents.ts b/apps/ai/src/mcp/tools/list-alert-incidents.ts index 50cba7c9b0..f2b1b675c3 100644 --- a/apps/ai/src/mcp/tools/list-alert-incidents.ts +++ b/apps/ai/src/mcp/tools/list-alert-incidents.ts @@ -25,6 +25,9 @@ export function registerListAlertIncidentsTool(server: McpToolRegistrar) { status: P.optionalOneOf(ALERT_INCIDENT_STATUSES, "Only incidents in this status"), severity: P.optionalOneOf(ALERT_SEVERITIES, "Only incidents with this severity"), group_key: P.optionalText("Only incidents with this exact group key"), + acknowledged: P.optionalFlag( + "true: open incidents someone acknowledged; false: open incidents nobody has. Implies status=open", + ), limit: P.limit({ default: 50, max: MAX_LIMIT, noun: "incidents" }), }), output: ListAlertIncidentsOutput, @@ -38,6 +41,9 @@ export function registerListAlertIncidentsTool(server: McpToolRegistrar) { const result = yield* readModels .listIncidents(tenant.orgId, { ...(params.status === undefined ? undefined : { status: params.status }), + ...(params.acknowledged === undefined + ? undefined + : { acknowledged: params.acknowledged }), limit: inMemoryFilter ? FILTERED_SCAN : params.limit + 1, }) .pipe( @@ -120,6 +126,7 @@ export function registerListAlertIncidentsTool(server: McpToolRegistrar) { ...(params.status === undefined ? undefined : { status: params.status }), ...(params.severity === undefined ? undefined : { severity: params.severity }), ...(params.group_key === undefined ? undefined : { groupKey: params.group_key }), + ...(params.acknowledged === undefined ? undefined : { acknowledged: params.acknowledged }), limit: params.limit, hasMore: matching.length > params.limit, } @@ -158,6 +165,10 @@ export function registerListAlertIncidentsTool(server: McpToolRegistrar) { ["Status", output.status], ["Severity", output.severity], ["Group", output.groupKey], + [ + "Acknowledged", + output.acknowledged === undefined ? undefined : output.acknowledged ? "yes" : "no", + ], ], ...(scanNotice === undefined ? undefined : { notices: [scanNotice] }), ...(output.incidents.length === 0 diff --git a/apps/api/src/routes/v2/alert-incidents.http.ts b/apps/api/src/routes/v2/alert-incidents.http.ts index 6ba0a28a02..9235babcda 100644 --- a/apps/api/src/routes/v2/alert-incidents.http.ts +++ b/apps/api/src/routes/v2/alert-incidents.http.ts @@ -64,8 +64,8 @@ export const HttpV2AlertIncidentsLive = HttpApiBuilder.group(MapleApiV2, "alertI const members = yield* OrgMembersService // Who is acknowledging, with a name recorded alongside so every surface can show it without a - // directory lookup. An API key is named by its label; a session by the member directory, best - // effort: a slow or unconfigured directory records the ack without a name. + // directory lookup. An API key is not a person: it is named by its label and has no user. A + // session is named by the member directory, best effort: a slow one records no name. const callerActor = Effect.gen(function* () { const tenant = yield* CurrentTenant.Context const credential = yield* CurrentAuditActor @@ -81,7 +81,7 @@ export const HttpV2AlertIncidentsLive = HttpApiBuilder.group(MapleApiV2, "alertI ), ) const actor: AlertIncidentActor = { - userId: tenant.userId, + userId: credential?.type === "api_key" ? null : tenant.userId, name, source: credential?.source === "dashboard" ? "web" : "api", } diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index facfbb506f..9db81c6bf2 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -1180,8 +1180,8 @@ describe("v2 alerts over HTTP", () => { expect(acked.status).toBe(200) expect(acked.body.status).toBe("open") expect(acked.body.acknowledged_at).toEqual(expect.any(String)) - // An API key is named by its label. - expect(acked.body.acknowledged_by).toEqual({ user_id: "user_alerts_e2e", name: "alerts-test" }) + // An API key is not a person: it is named by its label and has no user. + expect(acked.body.acknowledged_by).toEqual({ user_id: null, name: "alerts-test" }) expect(acked.body.acknowledged_via).toBe("api") const unacknowledgedOnly = await harness.request( @@ -1225,7 +1225,7 @@ describe("v2 alerts over HTTP", () => { expect(events.body.data[0]).toMatchObject({ object: "alert_incident_event", incident_id: openId, - actor: { user_id: "user_alerts_e2e", name: "alerts-test" }, + actor: { user_id: null, name: "alerts-test" }, source: "api", }) expect(events.body.data[0].id).toMatch(/^evt_/) diff --git a/packages/alerting-core/src/index.ts b/packages/alerting-core/src/index.ts index 8c4620679a..1cf542e41f 100644 --- a/packages/alerting-core/src/index.ts +++ b/packages/alerting-core/src/index.ts @@ -308,8 +308,6 @@ export interface AlertLifecyclePlan { readonly inheritedNotificationAtMs: number | null /** Whether the host must advance lastNotifiedAt before queueing the event. */ readonly advanceNotificationAnchor: boolean - /** Whether the host must clear the incident's acknowledgement (an escalation pages again). */ - readonly clearAcknowledgement: boolean } export interface AlertDeliveryRetryPolicy { @@ -362,7 +360,6 @@ const noTransition = ( hold, inheritedNotificationAtMs: null, advanceNotificationAnchor: false, - clearAcknowledgement: false, }) const resolutionPlan = ( @@ -380,7 +377,6 @@ const resolutionPlan = ( hold: null, inheritedNotificationAtMs: null, advanceNotificationAnchor: false, - clearAcknowledgement: false, } } @@ -445,7 +441,6 @@ export const planAlertLifecycle = (input: AlertLifecycleInput): Effect.Effect { transition: "continued", eventType: null, advanceNotificationAnchor: false, - clearAcknowledgement: false, }) }) - it("pages an escalation despite the ack, and asks the host to clear it", () => { + it("pages an escalation despite the ack", () => { expect(tick(25, openAt("warning", true))).toMatchObject({ transition: "escalated", eventType: "trigger", advanceNotificationAnchor: true, - clearAcknowledgement: true, }) - expect(tick(25, openAt("warning", false))).toMatchObject({ clearAcknowledgement: false }) }) it("de-escalates quietly while acknowledged", () => { @@ -344,7 +341,6 @@ describe("acknowledged incidents", () => { severity: "warning", eventType: null, advanceNotificationAnchor: false, - clearAcknowledgement: false, }) }) diff --git a/packages/backend/src/services/alerts/AlertsService.test.ts b/packages/backend/src/services/alerts/AlertsService.test.ts index 0082f6aa93..9a87d804c5 100644 --- a/packages/backend/src/services/alerts/AlertsService.test.ts +++ b/packages/backend/src/services/alerts/AlertsService.test.ts @@ -1056,6 +1056,88 @@ describe("AlertsService", () => { ) }) + it.effect( + "an acknowledged incident de-escalates quietly and escalates loudly, clearing the ack once", + () => { + const testDb = createTestDb(trackedDbs) + const state = { + tracesAggregateRows: errorRateRows(30) as ReadonlyArray>, + } + const requests: Array = [] + const tick = (alerts: AlertsServiceApi) => + Effect.gen(function* () { + yield* TestClock.adjust(Duration.minutes(1)) + yield* alerts.runSchedulerTick() + }) + const acknowledge = (orgId: OrgId) => + Effect.promise(() => + executeSql( + testDb, + `update alert_incidents set acknowledged_at = now(), acknowledged_by_name = 'Dana', + acknowledged_via = 'web' where org_id = $1`, + [orgId], + ), + ) + const ackEvents = (orgId: OrgId) => + Effect.promise(() => + testDb.pglite.query<{ type: string }>( + "select type from alert_incident_events where org_id = $1 order by created_at", + [orgId], + ), + ).pipe(Effect.map((result) => Arr.map(result.rows, (row) => row.type))) + + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const alerts = yield* AlertsService + const orgId = asOrgId("org_tiers_ack") + yield* createTieredSetup(alerts, orgId, asUserId("user_tiers_ack")) + + yield* alerts.runSchedulerTick() + yield* tick(alerts) + const opened = (yield* alerts.listIncidents(orgId)).incidents[0] + assert.strictEqual(opened?.severity, "critical") + yield* acknowledge(orgId) + + // Down to warning while acknowledged: no notice, the reminder clock stays where it was. + state.tracesAggregateRows = errorRateRows(10) + yield* tick(alerts) + yield* tick(alerts) + const quiet = (yield* alerts.listIncidents(orgId)).incidents[0] + assert.strictEqual(quiet?.severity, "warning") + assert.strictEqual(quiet?.lastNotifiedAt, opened?.lastNotifiedAt) + assert.isNotNull(quiet?.acknowledgedAt) + assert.lengthOf(requests, 2) + + // Back to critical: it pages despite the ack, and the ack is cleared with one event. + state.tracesAggregateRows = errorRateRows(30) + yield* tick(alerts) + yield* tick(alerts) + const escalated = (yield* alerts.listIncidents(orgId)).incidents[0] + assert.strictEqual(escalated?.severity, "critical") + assert.isNull(escalated?.acknowledgedAt) + assert.isNull(escalated?.acknowledgedByName) + assert.sameMembers(requests.slice(2), [ + "trigger https://example.com/warn", + "trigger https://example.com/page", + ]) + assert.deepStrictEqual(yield* ackEvents(orgId), ["ack_cleared"]) + + // An escalation with no ack standing writes no event. + state.tracesAggregateRows = errorRateRows(10) + yield* tick(alerts) + yield* tick(alerts) + state.tracesAggregateRows = errorRateRows(30) + yield* tick(alerts) + yield* tick(alerts) + assert.deepStrictEqual(yield* ackEvents(orgId), ["ack_cleared"]) + }).pipe( + Effect.provide( + makeLayer(testDb, makeWarehouseStub(state), { fetch: recordingFetch(requests) }), + ), + ) + }, + ) + it.effect("resolves a warning incident ended by a rule edit without paging", () => { const testDb = createTestDb(trackedDbs) const state = { tracesAggregateRows: errorRateRows(10) as ReadonlyArray> } diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index 175e3506d4..81c30b2a8b 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -3279,53 +3279,65 @@ 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, - lastNotifiedAt: lifecycle.advanceNotificationAnchor - ? timestamp - : openIncident.lastNotifiedAt, - ...(lifecycle.clearAcknowledgement - ? clearedAcknowledgement - : undefined), - }) - .where(($) => [$.id.eq(openIncident.id), $.status.eq("open")]) - .returning("id"), - ), + Effect.gen(function* () { + const updated = yield* 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, + lastNotifiedAt: lifecycle.advanceNotificationAnchor + ? timestamp + : openIncident.lastNotifiedAt, + }) + .where(($) => [$.id.eq(openIncident.id), $.status.eq("open")]) + .returning("id"), + ) + if (updated.length === 0 || lifecycle.transition !== "escalated") + return updated + const cleared = yield* db.run( + PG.update(AlertIncidents) + .set(clearedAcknowledgement) + .where(($) => [ + $.id.eq(openIncident.id), + $.status.eq("open"), + $.acknowledgedAt.isNotNull(), + ]) + .returning("id"), + ) + if (cleared.length > 0) { + yield* db.run( + PG.insertInto(AlertIncidentEvents).values({ + id: decodeAlertIncidentEventIdSync(makeUuid()), + orgId: row.orgId, + incidentId: openIncident.id, + type: "ack_cleared", + actorUserId: null, + actorName: null, + source: null, + createdAt: timestamp, + }), + ) + } + return updated + }), ) - if (refreshed.length > 0 && lifecycle.clearAcknowledgement) { - yield* dbExecute((db) => - db.run( - PG.insertInto(AlertIncidentEvents).values({ - id: decodeAlertIncidentEventIdSync(makeUuid()), - orgId: row.orgId, - incidentId: openIncident.id, - type: "ack_cleared", - actorUserId: null, - actorName: null, - source: null, - createdAt: timestamp, - }), - ), - ) - } if (refreshed.length === 0) { return { transition: "none" as const, diff --git a/packages/domain/src/http/v2/alert-incidents.ts b/packages/domain/src/http/v2/alert-incidents.ts index 5dd1e7e177..6b73520577 100644 --- a/packages/domain/src/http/v2/alert-incidents.ts +++ b/packages/domain/src/http/v2/alert-incidents.ts @@ -282,7 +282,7 @@ export class V2AlertIncidentsApiGroup extends HttpApiGroup.make("alertIncidents" identifier: "unacknowledgeAlertIncident", summary: "Unacknowledge an alert incident", description: - "Clears an open incident's acknowledgement, so reminders resume on their usual schedule. Fails with 409 on a resolved incident. Requires the `alerts:write` scope.", + "Clears an open incident's acknowledgement, so reminders resume: one that came due while it was acknowledged is sent on the next check. Fails with 409 on a resolved incident. Requires the `alerts:write` scope.", }), ), ) @@ -296,7 +296,7 @@ export class V2AlertIncidentsApiGroup extends HttpApiGroup.make("alertIncidents" identifier: "listAlertIncidentEvents", summary: "List an alert incident's events", description: - "Returns the incident's acknowledgement history, oldest first. Requires the `alerts:read` scope.", + "Returns the incident's acknowledgement history, oldest first, up to its 500 most recent entries. Requires the `alerts:read` scope.", }), ), ) diff --git a/packages/domain/src/mcp-outputs/alerts.ts b/packages/domain/src/mcp-outputs/alerts.ts index 884043578e..17e5776ed7 100644 --- a/packages/domain/src/mcp-outputs/alerts.ts +++ b/packages/domain/src/mcp-outputs/alerts.ts @@ -231,6 +231,7 @@ const IncidentFilters = { status: Schema.optionalKey(Schema.String), severity: Schema.optionalKey(Schema.String), groupKey: Schema.optionalKey(Schema.String), + acknowledged: Schema.optionalKey(Schema.Boolean), limit: Schema.optionalKey(Schema.Number), /** More incidents matched than `limit` let through. */ hasMore: Schema.optionalKey(Schema.Boolean), @@ -307,6 +308,7 @@ export const IncidentTimelineRow = Schema.Struct({ export const AcknowledgeAlertIncidentOutput = Schema.Struct({ id: Schema.String, + ruleId: Schema.String, ruleName: Schema.String, status: Schema.String, acknowledged: Schema.Boolean, From 836f27a97aa899c5371c79fb01fca415a6cd706c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 01:37:36 +0100 Subject: [PATCH 03/98] chore(ios): regenerate the OpenAPI spec for the incident acknowledgement fields --- .../MapleAPI/Sources/MapleAPI/openapi.json | 72 ++++++++++++++++++- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index e7cfd2f0cc..1813a6c8dd 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -301,9 +301,15 @@ }, "AlertIncident": { "additionalProperties": false, - "description": "A period during which an alert rule's condition held: opened after enough consecutive breaches, resolved by the scheduler after enough consecutive healthy checks. Read-only via the API.", + "description": "A period during which an alert rule's condition held: opened after enough consecutive breaches, resolved by the scheduler after enough consecutive healthy checks. An open incident can be acknowledged to pause reminders; resolution is scheduler-driven.", "examples": [ { + "acknowledged_at": "2026-07-15T09:14:02.000Z", + "acknowledged_by": { + "name": "Dana Kim", + "user_id": "user_2Nk8mXqPfR3yZ1aB4cD5eF6g" + }, + "acknowledged_via": "slack", "comparator": "gt", "dedupe_key": "alrt_gU26thvJECdQvu54Ad9jiz:__total__", "error_issue_id": null, @@ -330,6 +336,21 @@ } ], "properties": { + "acknowledged_at": { + "description": "When someone acknowledged this open incident, or `null`. While acknowledged, reminders (`renotify`) pause; an escalation to a higher severity still notifies and clears the acknowledgement. Cleared when the incident resolves.", + "type": "string" + }, + "acknowledged_by": { + "$ref": "#/components/schemas/AlertIncidentActor", + "description": "Who acknowledged the incident, or `null` when it is not acknowledged." + }, + "acknowledged_via": { + "$ref": "#/components/schemas/_maple_AlertIncidentAckSource", + "description": "Where the acknowledgement came from (`web`, `api`, `mcp`, `slack`, `discord`), or `null` when not acknowledged.", + "examples": [ + "slack" + ] + }, "comparator": { "$ref": "#/components/schemas/_maple_AlertComparator" }, @@ -445,6 +466,24 @@ "title": "Alert Incident", "type": "object" }, + "AlertIncidentActor": { + "additionalProperties": false, + "properties": { + "name": { + "description": "Display name as the source reported it, or `null` when none was available.", + "examples": [ + "Dana Kim" + ], + "type": "string" + }, + "user_id": { + "$ref": "#/components/schemas/_maple_UserId", + "description": "The Maple user, or `null` for a chat account not linked to one." + } + }, + "title": "Alert incident actor", + "type": "object" + }, "AlertIncidentList": { "additionalProperties": false, "description": "A cursor-paginated page of alert incidents.", @@ -6859,6 +6898,17 @@ "title": "Alert Event Type", "type": "string" }, + "_maple_AlertIncidentAckSource": { + "enum": [ + "web", + "api", + "mcp", + "slack", + "discord" + ], + "title": "Alert Incident Acknowledgement Source", + "type": "string" + }, "_maple_AlertIncidentHoldReason": { "enum": [ "no_data", @@ -7478,6 +7528,22 @@ "schema": { "$ref": "#/components/schemas/_maple_AlertRuleId" } + }, + { + "in": "query", + "name": "acknowledged", + "required": false, + "schema": { + "description": "`true` for open incidents someone has acknowledged, `false` for open incidents nobody has. Implies `status=open`.", + "enum": [ + "true", + "false" + ], + "examples": [ + "false" + ], + "type": "string" + } } ], "responses": { @@ -7613,7 +7679,7 @@ } } }, - "description": "A period during which an alert rule's condition held: opened after enough consecutive breaches, resolved by the scheduler after enough consecutive healthy checks. Read-only via the API." + "description": "A period during which an alert rule's condition held: opened after enough consecutive breaches, resolved by the scheduler after enough consecutive healthy checks. An open incident can be acknowledged to pause reminders; resolution is scheduler-driven." }, "400": { "content": { @@ -13982,7 +14048,7 @@ "name": "Alert Destinations" }, { - "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.", + "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. An open incident can be acknowledged to pause its reminders.", "name": "Alert Incidents" }, { From 7ec39ded6273da0cea9d2c716f47ee560d9cbf65 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 01:45:41 +0100 Subject: [PATCH 04/98] fix(alerts): keep the acknowledged filter on the incident list's next page, list the newest events --- apps/ai/src/mcp/tools/list-alert-incidents.ts | 1 + .../backend/src/services/alerts/AlertIncidentAckService.ts | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/ai/src/mcp/tools/list-alert-incidents.ts b/apps/ai/src/mcp/tools/list-alert-incidents.ts index f2b1b675c3..054ed051c3 100644 --- a/apps/ai/src/mcp/tools/list-alert-incidents.ts +++ b/apps/ai/src/mcp/tools/list-alert-incidents.ts @@ -236,6 +236,7 @@ export function registerListAlertIncidentsTool(server: McpToolRegistrar) { status: output.status, severity: output.severity, group_key: output.groupKey, + acknowledged: output.acknowledged, limit: MAX_LIMIT, }, "more incidents", diff --git a/packages/backend/src/services/alerts/AlertIncidentAckService.ts b/packages/backend/src/services/alerts/AlertIncidentAckService.ts index 5655e744ad..00617553df 100644 --- a/packages/backend/src/services/alerts/AlertIncidentAckService.ts +++ b/packages/backend/src/services/alerts/AlertIncidentAckService.ts @@ -191,13 +191,14 @@ export class AlertIncidentAckService extends Context.Service< PG.from(AlertIncidentEvents) .select() .where(($) => [$.orgId.eq(orgId), $.incidentId.eq(incidentId)]) - .orderBy(["createdAt", "asc"], ["id", "asc"]) + // The newest 500, put back in order below. + .orderBy(["createdAt", "desc"], ["id", "desc"]) .limit(500), ), ) // A type this build does not know (a newer writer) is skipped rather than failing the page. return Arr.getSomes( - Arr.map(rows, (row) => + Arr.map(Arr.reverse(rows), (row) => Option.map( decodeEventType(row.type), (type) => From 9eaf37ecbd9a2486e2560364471d9ff71a3517e1 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 01:49:47 +0100 Subject: [PATCH 05/98] chore(domain): regenerate anticipated errors for AlertIncidentResolvedError --- 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..441ce1dac5 100644 --- a/packages/domain/src/generated/anticipated-error-identifiers.ts +++ b/packages/domain/src/generated/anticipated-error-identifiers.ts @@ -19,6 +19,7 @@ export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray = [ "@maple/http/errors/AlertDestinationNotFoundError", "@maple/http/errors/AlertForbiddenError", "@maple/http/errors/AlertIncidentNotFoundError", + "@maple/http/errors/AlertIncidentResolvedError", "@maple/http/errors/AlertRecipientSelectionError", "@maple/http/errors/AlertRuleDestinationNotFoundError", "@maple/http/errors/AlertRuleNotFoundError", From 6577df528259e6843ac9ba8d8ae80d0c13beb67f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 02:27:04 +0100 Subject: [PATCH 06/98] fix(alerts): leave the internal incident id out of the resolved-incident message --- packages/backend/src/services/alerts/AlertIncidentAckService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/services/alerts/AlertIncidentAckService.ts b/packages/backend/src/services/alerts/AlertIncidentAckService.ts index 00617553df..4dc8e751ba 100644 --- a/packages/backend/src/services/alerts/AlertIncidentAckService.ts +++ b/packages/backend/src/services/alerts/AlertIncidentAckService.ts @@ -172,7 +172,7 @@ export class AlertIncidentAckService extends Context.Service< const current = yield* findIncident(orgId, incidentId) if (current.status !== "open") { return yield* new AlertIncidentResolvedError({ - message: `Alert incident '${incidentId}' is resolved; only an open incident can be ${acknowledged ? "acknowledged" : "unacknowledged"}.`, + message: `This alert incident is resolved; only an open incident can be ${acknowledged ? "acknowledged" : "unacknowledged"}.`, incidentId, }) } From 5abdc40680a215039b2cad540819528dcc0138c5 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 02:40:26 +0100 Subject: [PATCH 07/98] fix(alerts): attribute MCP acks like the audit log, stamp ack_cleared at the write --- .../tools/acknowledge-alert-incident.test.ts | 20 +++++++++++++++++++ .../mcp/tools/acknowledge-alert-incident.ts | 11 +++++++++- .../src/services/alerts/AlertsService.ts | 5 ++++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/ai/src/mcp/tools/acknowledge-alert-incident.test.ts b/apps/ai/src/mcp/tools/acknowledge-alert-incident.test.ts index 7e0e0a3295..e992aef5bb 100644 --- a/apps/ai/src/mcp/tools/acknowledge-alert-incident.test.ts +++ b/apps/ai/src/mcp/tools/acknowledge-alert-incident.test.ts @@ -7,6 +7,7 @@ import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@maple/ba import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import { CurrentMcpTenant } from "../lib/query-warehouse" import { registerAcknowledgeAlertIncidentTool } from "./acknowledge-alert-incident" +import { CurrentAuditActor } from "@maple/backend/services/auth/audit-actor" import type { McpToolError, McpToolRegistrar } from "./types" import type { McpToolRequirements } from "./runtime-requirements" import { unimplementedMcpToolServices } from "../../../test/mcp/unimplemented-tool-services" @@ -126,4 +127,23 @@ describe("acknowledge_alert_incident", () => { }) }).pipe(Effect.provide(layerFor(testDb))) }) + + it.effect("records Maple, not the token's placeholder user, when Maple itself acknowledges", () => { + const testDb = createTestDb(trackedDbs) + const call = acknowledgeTool() + return Effect.gen(function* () { + yield* seed(testDb) + const acked = yield* call({ incident_id: OPEN }).pipe( + Effect.provideService(CurrentAuditActor, { type: "system", source: "system" }), + ) + assert.deepInclude(acked, { acknowledged: true, acknowledgedBy: "Maple" }) + const rows = yield* Effect.promise(() => + testDb.pglite.query<{ acknowledged_by_user_id: string | null }>( + "select acknowledged_by_user_id from alert_incidents where id = $1", + [OPEN], + ), + ) + assert.isNull(rows.rows[0]?.acknowledged_by_user_id) + }).pipe(Effect.provide(layerFor(testDb))) + }) }) diff --git a/apps/ai/src/mcp/tools/acknowledge-alert-incident.ts b/apps/ai/src/mcp/tools/acknowledge-alert-incident.ts index 8b4a07bb3a..2e3416faec 100644 --- a/apps/ai/src/mcp/tools/acknowledge-alert-incident.ts +++ b/apps/ai/src/mcp/tools/acknowledge-alert-incident.ts @@ -2,6 +2,8 @@ import { McpInvalidInputError, McpQueryError, type McpToolRegistrar } from "./ty import { Effect, Schema } from "effect" import { AcknowledgeAlertIncidentOutput } from "@maple/domain/mcp-outputs" import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" +import { auditAttribution } from "@maple/backend/services/audit/audit-access" +import { CurrentAuditActor } from "@maple/backend/services/auth/audit-actor" import { CurrentMcpTenant } from "../lib/query-warehouse" import { ackLabel } from "../lib/alert-rules" import * as P from "../lib/params" @@ -27,7 +29,14 @@ export function registerAcknowledgeAlertIncidentTool(server: McpToolRegistrar) { const tenant = yield* CurrentMcpTenant const acks = yield* AlertIncidentAckService const acknowledge = params.acknowledged !== false - const actor = { userId: tenant.userId, name: null, source: "mcp" as const } + // Attributed as the audit log attributes this call: Maple itself and a chat connector's + // placeholder carry no user, an agent is named by its client. + const attribution = auditAttribution(tenant, yield* CurrentAuditActor) + const actor = { + userId: attribution.actor.userId ?? null, + name: attribution.actor.label ?? (attribution.actor.type === "system" ? "Maple" : null), + source: "mcp" as const, + } const result = yield* ( acknowledge diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index 81c30b2a8b..8c03a48b72 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -3322,6 +3322,9 @@ export class AlertsService extends Context.Service 0) { + // Stamped at the write, not the tick's start: an ack that committed during + // the evaluation must not sort after the event that cleared it. + const clearedAt = Math.max(timestamp, yield* now) yield* db.run( PG.insertInto(AlertIncidentEvents).values({ id: decodeAlertIncidentEventIdSync(makeUuid()), @@ -3331,7 +3334,7 @@ export class AlertsService extends Context.Service Date: Fri, 6 Nov 2026 02:40:54 +0100 Subject: [PATCH 08/98] chore(alerts): yield before the call --- apps/ai/src/mcp/tools/acknowledge-alert-incident.ts | 3 ++- packages/backend/src/services/alerts/AlertsService.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/ai/src/mcp/tools/acknowledge-alert-incident.ts b/apps/ai/src/mcp/tools/acknowledge-alert-incident.ts index 2e3416faec..bbe82483a7 100644 --- a/apps/ai/src/mcp/tools/acknowledge-alert-incident.ts +++ b/apps/ai/src/mcp/tools/acknowledge-alert-incident.ts @@ -31,7 +31,8 @@ export function registerAcknowledgeAlertIncidentTool(server: McpToolRegistrar) { const acknowledge = params.acknowledged !== false // Attributed as the audit log attributes this call: Maple itself and a chat connector's // placeholder carry no user, an agent is named by its client. - const attribution = auditAttribution(tenant, yield* CurrentAuditActor) + const credential = yield* CurrentAuditActor + const attribution = auditAttribution(tenant, credential) const actor = { userId: attribution.actor.userId ?? null, name: attribution.actor.label ?? (attribution.actor.type === "system" ? "Maple" : null), diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index 8c03a48b72..1bf79102a1 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -3324,7 +3324,8 @@ export class AlertsService extends Context.Service 0) { // Stamped at the write, not the tick's start: an ack that committed during // the evaluation must not sort after the event that cleared it. - const clearedAt = Math.max(timestamp, yield* now) + const writeTime = yield* now + const clearedAt = Math.max(timestamp, writeTime) yield* db.run( PG.insertInto(AlertIncidentEvents).values({ id: decodeAlertIncidentEventIdSync(makeUuid()), From a929a95182eadcfbf4ad90be79df566b0ff8fbf3 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 12:47:41 +0100 Subject: [PATCH 09/98] feat(alerts): escalation chains that page step by step until someone acknowledges --- packages/alerting-core/src/escalation.test.ts | 180 + packages/alerting-core/src/escalation.ts | 96 + packages/alerting-core/src/index.ts | 1 + .../alerts/AlertDestinationsService.ts | 38 +- .../src/services/alerts/AlertRulesService.ts | 23 +- .../src/services/alerts/AlertsService.ts | 524 +- .../alerts/EscalationChainsService.ts | 366 + .../services/alerts/alert-delivery-rows.ts | 50 + .../services/alerts/alert-escalation-runs.ts | 293 + .../src/services/alerts/alert-thresholds.ts | 25 +- .../src/services/org/OrganizationService.ts | 4 + .../migration.sql | 52 + .../snapshot.json | 19340 ++++++++++++++++ packages/db/src/tables/escalation-chains.ts | 88 + packages/db/src/tables/index.ts | 4 + packages/domain/src/http/alerts.ts | 3 + packages/domain/src/http/escalation-chains.ts | 128 + packages/domain/src/http/index.ts | 1 + packages/primitives/src/index.ts | 4 + 19 files changed, 21057 insertions(+), 163 deletions(-) create mode 100644 packages/alerting-core/src/escalation.test.ts create mode 100644 packages/alerting-core/src/escalation.ts create mode 100644 packages/backend/src/services/alerts/EscalationChainsService.ts create mode 100644 packages/backend/src/services/alerts/alert-delivery-rows.ts create mode 100644 packages/backend/src/services/alerts/alert-escalation-runs.ts create mode 100644 packages/db/drizzle/20261106113702_escalation_chains/migration.sql create mode 100644 packages/db/drizzle/20261106113702_escalation_chains/snapshot.json create mode 100644 packages/db/src/tables/escalation-chains.ts create mode 100644 packages/domain/src/http/escalation-chains.ts diff --git a/packages/alerting-core/src/escalation.test.ts b/packages/alerting-core/src/escalation.test.ts new file mode 100644 index 0000000000..d517c438aa --- /dev/null +++ b/packages/alerting-core/src/escalation.test.ts @@ -0,0 +1,180 @@ +import { AlertDestinationId, type EscalationSteps } from "@maple/domain/http" +import { Schema } from "effect" +import { describe, expect, it } from "vitest" +import { + ESCALATION_SILENCE_RECHECK_MS, + type EscalationIncidentState, + type EscalationRunPosition, + escalationStepCount, + planEscalationAdvance, +} from "./escalation" + +const destinationIds = { + slack: "00000000-0000-4000-8000-000000000001", + pagerduty: "00000000-0000-4000-8000-000000000002", + email: "00000000-0000-4000-8000-000000000003", +} +const destination = (name: keyof typeof destinationIds) => ({ + type: "destination" as const, + destinationId: Schema.decodeSync(AlertDestinationId)(destinationIds[name]), +}) + +// Slack, wait 10; PagerDuty, wait 15; email, wait 15. +const steps: EscalationSteps = [ + { targets: [destination("slack")], waitMinutes: 10 }, + { targets: [destination("pagerduty")], waitMinutes: 15 }, + { targets: [destination("email")], waitMinutes: 15 }, +] + +const T0 = Date.UTC(2026, 10, 6, 14, 0) +const minutes = (n: number) => T0 + n * 60_000 + +const run = (overrides: Partial = {}): EscalationRunPosition => ({ + status: "active", + steps, + repeatCount: 0, + stepIndex: 0, + passIndex: 0, + nextStepAt: T0, + ...overrides, +}) + +const firing: EscalationIncidentState = { open: true, acknowledged: false, silenced: false } + +describe("planEscalationAdvance", () => { + it("sends the due step and schedules the next one its wait later", () => { + const plan = planEscalationAdvance(run(), firing, T0) + expect(plan).toMatchObject({ + kind: "send", + stepIndex: 0, + passIndex: 0, + step: { targets: [{ destinationId: destinationIds.slack }] }, + next: { kind: "step", stepIndex: 1, passIndex: 0, nextStepAt: minutes(10) }, + }) + }) + + it("waits until the step is due", () => { + expect( + planEscalationAdvance(run({ stepIndex: 1, nextStepAt: minutes(10) }), firing, minutes(9)), + ).toEqual({ + kind: "wait", + }) + }) + + it("counts the wait from when a late step was actually sent", () => { + const plan = planEscalationAdvance( + run({ stepIndex: 1, nextStepAt: minutes(10) }), + firing, + minutes(11), + ) + expect(plan).toMatchObject({ kind: "send", next: { nextStepAt: minutes(26) } }) + }) + + it("walks the email example: page at 14:10, stop at the 14:12 ack, never send step 3", () => { + const atTen = planEscalationAdvance( + run({ stepIndex: 1, nextStepAt: minutes(10) }), + firing, + minutes(10), + ) + expect(atTen).toMatchObject({ + kind: "send", + step: { targets: [{ destinationId: destinationIds.pagerduty }] }, + }) + const acked = { ...firing, acknowledged: true } + expect( + planEscalationAdvance(run({ stepIndex: 2, nextStepAt: minutes(25) }), acked, minutes(12)), + ).toEqual({ + kind: "pause", + }) + expect( + planEscalationAdvance( + run({ status: "acknowledged", stepIndex: 2, nextStepAt: minutes(25) }), + acked, + minutes(30), + ), + ).toEqual({ kind: "wait" }) + }) + + it("resumes an unacknowledged run where it stopped", () => { + const paused = run({ status: "acknowledged", stepIndex: 2, nextStepAt: minutes(25) }) + expect(planEscalationAdvance(paused, firing, minutes(20))).toEqual({ kind: "resume" }) + expect(planEscalationAdvance(paused, firing, minutes(40))).toMatchObject({ + kind: "send", + stepIndex: 2, + }) + }) + + it("ends without sending once the incident resolves, even mid-wait", () => { + const resolved = { ...firing, open: false } + expect(planEscalationAdvance(run({ nextStepAt: minutes(30) }), resolved, minutes(1))).toEqual({ + kind: "end", + status: "resolved", + }) + }) + + it("holds a due step through a silence and sends it once the silence ends", () => { + const silenced = { ...firing, silenced: true } + const due = run({ stepIndex: 1, nextStepAt: minutes(10) }) + expect(planEscalationAdvance(due, silenced, minutes(10))).toEqual({ + kind: "defer", + nextStepAt: minutes(10) + ESCALATION_SILENCE_RECHECK_MS, + }) + expect( + planEscalationAdvance(run({ stepIndex: 1, nextStepAt: minutes(30) }), firing, minutes(30)), + ).toMatchObject({ + kind: "send", + stepIndex: 1, + }) + }) + + it("repeats the whole chain, then reports it spent after the last step of the last pass", () => { + const repeating = { repeatCount: 1 } + expect(planEscalationAdvance(run({ ...repeating, stepIndex: 2 }), firing, T0)).toMatchObject({ + kind: "send", + next: { kind: "step", stepIndex: 0, passIndex: 1, nextStepAt: minutes(15) }, + }) + expect( + planEscalationAdvance(run({ ...repeating, stepIndex: 2, passIndex: 1 }), firing, T0), + ).toMatchObject({ + kind: "send", + stepIndex: 2, + passIndex: 1, + next: { kind: "exhausted" }, + }) + }) + + it("ends a run whose position is past its chain instead of sending", () => { + expect(planEscalationAdvance(run({ stepIndex: 7 }), firing, T0)).toEqual({ + kind: "end", + status: "exhausted", + }) + }) + + it("leaves ended runs alone", () => { + expect(planEscalationAdvance(run({ status: "exhausted" }), firing, minutes(90))).toEqual({ + kind: "wait", + }) + expect(planEscalationAdvance(run({ status: "superseded" }), { ...firing, open: false }, T0)).toEqual({ + kind: "wait", + }) + }) + + it("sends every step of every pass exactly once when nobody acknowledges", () => { + const walk = (position: EscalationRunPosition, nowMs: number, sent: number): number => { + const plan = planEscalationAdvance(position, firing, nowMs) + if (plan.kind !== "send") return sent + if (plan.next.kind === "exhausted") return sent + 1 + return walk( + { + ...position, + stepIndex: plan.next.stepIndex, + passIndex: plan.next.passIndex, + nextStepAt: plan.next.nextStepAt, + }, + plan.next.nextStepAt, + sent + 1, + ) + } + expect(walk(run({ repeatCount: 2 }), T0, 0)).toBe(escalationStepCount(steps, 2)) + }) +}) diff --git a/packages/alerting-core/src/escalation.ts b/packages/alerting-core/src/escalation.ts new file mode 100644 index 0000000000..0cb70aa653 --- /dev/null +++ b/packages/alerting-core/src/escalation.ts @@ -0,0 +1,96 @@ +import type { AlertEscalationRunStatus, EscalationStep, EscalationSteps } from "@maple/domain/http" + +/** + * Escalation chains: an incident walks a chain's steps, each notifying its targets and waiting + * `waitMinutes` for an acknowledgement before the next. Pure; the scheduler applies the plan + * with a compare-and-set on (stepIndex, passIndex) so each step is sent at most once. + */ + +/** How long a silenced run waits before the scheduler looks at it again. */ +export const ESCALATION_SILENCE_RECHECK_MS = 60_000 + +export interface EscalationRunPosition { + readonly status: AlertEscalationRunStatus + readonly steps: EscalationSteps + readonly repeatCount: number + readonly stepIndex: number + readonly passIndex: number + readonly nextStepAt: number +} + +export interface EscalationIncidentState { + readonly open: boolean + readonly acknowledged: boolean + /** A silence covers the incident right now. */ + readonly silenced: boolean +} + +export type EscalationAdvancePlan = + /** Nothing to write: not due, or already paused for the same reason. */ + | { readonly kind: "wait" } + /** The incident resolved, or the position is past the chain: the run ends without sending. */ + | { readonly kind: "end"; readonly status: "resolved" | "exhausted" } + /** Someone acknowledged: the run holds its place until an unacknowledge. */ + | { readonly kind: "pause" } + /** Unacknowledged before the step was due: the run goes back to waiting. */ + | { readonly kind: "resume" } + /** Due but silenced: look again later, sending nothing. Leaves an acknowledged run active. */ + | { readonly kind: "defer"; readonly nextStepAt: number } + /** Send `step`, then move to the next position, or end when the chain is spent. */ + | { + readonly kind: "send" + readonly step: EscalationStep + readonly stepIndex: number + readonly passIndex: number + readonly next: + | { + readonly kind: "step" + readonly stepIndex: number + readonly passIndex: number + readonly nextStepAt: number + } + | { readonly kind: "exhausted" } + } + +const nextPosition = ( + run: EscalationRunPosition, + step: EscalationStep, + nowMs: number, +): Extract["next"] => { + const nextStepAt = nowMs + step.waitMinutes * 60_000 + if (run.stepIndex + 1 < run.steps.length) { + return { kind: "step", stepIndex: run.stepIndex + 1, passIndex: run.passIndex, nextStepAt } + } + if (run.passIndex < run.repeatCount) { + return { kind: "step", stepIndex: 0, passIndex: run.passIndex + 1, nextStepAt } + } + return { kind: "exhausted" } +} + +export const planEscalationAdvance = ( + run: EscalationRunPosition, + incident: EscalationIncidentState, + nowMs: number, +): EscalationAdvancePlan => { + if (run.status !== "active" && run.status !== "acknowledged") return { kind: "wait" } + if (!incident.open) return { kind: "end", status: "resolved" } + if (incident.acknowledged) return run.status === "acknowledged" ? { kind: "wait" } : { kind: "pause" } + const due = run.nextStepAt <= nowMs + if (run.status === "acknowledged" && !due) return { kind: "resume" } + if (!due) return { kind: "wait" } + if (incident.silenced) return { kind: "defer", nextStepAt: nowMs + ESCALATION_SILENCE_RECHECK_MS } + const step = run.steps[run.stepIndex] + // A position past the snapshot cannot be sent; treat the chain as spent. + if (step === undefined) return { kind: "end", status: "exhausted" } + return { + kind: "send", + step, + stepIndex: run.stepIndex, + passIndex: run.passIndex, + next: nextPosition(run, step, nowMs), + } +} + +/** Total steps a chain sends when nobody acknowledges. */ +export const escalationStepCount = (steps: EscalationSteps, repeatCount: number): number => + steps.length * (repeatCount + 1) diff --git a/packages/alerting-core/src/index.ts b/packages/alerting-core/src/index.ts index 1cf542e41f..767c12bff8 100644 --- a/packages/alerting-core/src/index.ts +++ b/packages/alerting-core/src/index.ts @@ -22,6 +22,7 @@ import type { } from "@maple/domain/http" export type { AlertComparator, AlertEventType, AlertEvaluationStatus } from "@maple/domain/http" export * from "./tiers" +export * from "./escalation" export interface AlertObservation { readonly value: number | null diff --git a/packages/backend/src/services/alerts/AlertDestinationsService.ts b/packages/backend/src/services/alerts/AlertDestinationsService.ts index 3ce9fe0d01..2b692e72ab 100644 --- a/packages/backend/src/services/alerts/AlertDestinationsService.ts +++ b/packages/backend/src/services/alerts/AlertDestinationsService.ts @@ -29,7 +29,13 @@ import { } from "@maple/domain/http" import * as Orm from "@maple-dev/effect-orm/database" import * as PG from "@maple-dev/effect-orm/postgres" -import { AlertDestinations, AlertLinearIssues, AlertRules, type AlertDestinationRow } from "@maple/db/tables" +import { + AlertDestinations, + AlertLinearIssues, + AlertRules, + EscalationChains, + type AlertDestinationRow, +} from "@maple/db/tables" import { Context, Effect, Layer, Match, Option, Redacted, Schema, Array as Arr, String as Str } from "effect" import { HttpClient } from "effect/http" import { encryptAes256Gcm, type EncryptedValue } from "@maple/backend/platform/Crypto" @@ -1047,17 +1053,43 @@ export class AlertDestinationsService extends Context.Service< ]), ) if (stillReferenced.length > 0) { - return { referencedBy: stillReferenced, deleted: [] } + return { referencedBy: stillReferenced, chainNames: [], deleted: [] } + } + // Chain writes validate their step destinations under the same lock. + const chains = yield* db.run( + PG.from(EscalationChains) + .select("name") + .where(($) => [ + $.orgId.eq(orgId), + PG.sql.cond`${$.stepsJson} @> ${encodeJson([ + { targets: [{ type: "destination", destinationId }] }, + ])}::jsonb`, + ]), + ) + if (chains.length > 0) { + return { + referencedBy: [], + chainNames: Arr.map(chains, (chain) => chain.name), + deleted: [], + } } const deleted = yield* db.run( PG.deleteFrom(AlertDestinations) .where(($) => [$.orgId.eq(orgId), $.id.eq(destinationId)]) .returning(() => ({ txid: currentTxid })), ) - return { referencedBy: [], deleted } + return { referencedBy: [], chainNames: [], deleted } }), ), ) + if (deleteResult.chainNames.length > 0) { + return yield* new AlertDestinationInUseError({ + message: `Destination is still used by escalation chains: ${deleteResult.chainNames.join(", ")}`, + destinationId, + ruleIds: [], + ruleNames: [], + }) + } if (deleteResult.referencedBy.length > 0) { return yield* new AlertDestinationInUseError({ message: `Destination is still used by alert rules: ${deleteResult.referencedBy diff --git a/packages/backend/src/services/alerts/AlertRulesService.ts b/packages/backend/src/services/alerts/AlertRulesService.ts index 66009f1b59..8c8cfc86b4 100644 --- a/packages/backend/src/services/alerts/AlertRulesService.ts +++ b/packages/backend/src/services/alerts/AlertRulesService.ts @@ -25,8 +25,9 @@ import { AlertRuleClaims, AlertRules, AlertRuleStates, + EscalationChains, } from "@maple/db/tables" -import { Array as Arr, Context, Effect, HashSet, Layer, Option, Schema } from "effect" +import { Array as Arr, Context, Effect, HashSet, Layer, Option, Result, Schema } from "effect" import type { MapleDb } from "@maple/db/client" import { Database, type DatabaseApi } from "@maple/backend/platform/DatabaseLive" import { makeDbExecute } from "@maple/backend/platform/db-execute" @@ -248,6 +249,26 @@ export const makeAlertRulePersistence = (options: { }) } } + // Chains likewise: their deletion scans rules for references under this lock. + const chainIds = Arr.dedupe( + Arr.filterMap(normalized.thresholds, (tier) => + tier.escalationChainId == null + ? Result.failVoid + : Result.succeed(tier.escalationChainId), + ), + ) + if (chainIds.length > 0) { + const chainRows = yield* db.run( + PG.from(EscalationChains) + .select("id") + .where(($) => [$.orgId.eq(orgId), $.id.in_(...chainIds)]), + ) + if (chainRows.length < chainIds.length) { + return yield* makeAlertValidationError( + "Alert rule references an unknown escalation chain", + ) + } + } if (normalized.enabled) { const activeRows = yield* db.run( PG.from(AlertRules) diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index 1bf79102a1..bf9892fb30 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -32,7 +32,6 @@ import { AlertDeliveryRejectedError, AlertDeliveryTargetMissingError, AlertDestinationDecryptionError, - AlertDeliveryEventDocument, AlertDestinationDocument, AlertDestinationStoredConfigInvalidError, AlertEvaluationResult, @@ -40,6 +39,7 @@ import { AlertForbiddenError, AlertIncidentDocument, AlertIncidentEventId, + AlertEscalationRunId, AlertIncidentHoldReason, AlertIncidentStatus, type AlertDestinationNotFoundError, @@ -56,6 +56,7 @@ import { type AlertRulePreviewRequest, AlertSeverity as AlertSeveritySchema, type AlertSeverity, + type EscalationChainId, AlertSignalType as AlertSignalTypeSchema, AlertValidationError, AlertNotificationTemplate, @@ -141,7 +142,17 @@ import { import { ChatSessions } from "@maple/backend/platform/bindings" import { Database } from "@maple/backend/platform/DatabaseLive" import { formatComparator } from "./alert-formatting" -import { ruleAtSeverity } from "./alert-thresholds" +import { escalationChainFor, ruleAtSeverity } from "./alert-thresholds" +import { + applyEscalationPlan, + enabledDestinationIds, + type EscalationWorkRow, + loadEscalationChain, + planEscalationWork, + selectEscalationWork, + startEscalationRun, +} from "./alert-escalation-runs" +import { queuedDeliveryEventRow } from "./alert-delivery-rows" import { buildChatDigestBlocks, CHAT_DIGEST_THRESHOLD, type ChatDigestItem } from "./delivery/chat-digest" import { makeIncidentPushBudget, type IncidentPushBudget } from "./alert-push-budget" import { EmailService } from "@maple/backend/platform/EmailService" @@ -435,8 +446,8 @@ const StoredDeliveryPayloadSchema = Schema.StructWithRest( const decodeAlertRuleIdSync = Schema.decodeUnknownSync(AlertRuleDocument.fields.id) const decodeAlertIncidentIdSync = Schema.decodeUnknownSync(AlertIncidentDocument.fields.id) -const decodeAlertDeliveryEventIdSync = Schema.decodeUnknownSync(AlertDeliveryEventDocument.fields.id) const decodeAlertIncidentEventIdSync = Schema.decodeUnknownSync(AlertIncidentEventId) +const decodeAlertEscalationRunIdSync = Schema.decodeUnknownSync(AlertEscalationRunId) /** An incident that leaves `open` stops being acknowledged; its history stays in `alert_incident_events`. */ const clearedAcknowledgement = { @@ -1182,29 +1193,20 @@ export class AlertsService extends Context.Service orm.run( PG.insertInto(AlertDeliveryEvents) - .values({ - id: decodeAlertDeliveryEventIdSync(makeUuid()), - orgId, - incidentId, - ruleId, - destinationId, - deliveryKey, - eventType, - attemptNumber, - status: "queued", - scheduledAt: scheduledAt, - claimedAt: null, - claimExpiresAt: null, - claimedBy: null, - attemptedAt: null, - providerMessage: null, - providerReference: null, - responseCode: null, - errorMessage: null, - payloadJson: payload, - createdAt: scheduledAt, - updatedAt: scheduledAt, - }) + .values( + queuedDeliveryEventRow({ + id: makeUuid(), + orgId, + incidentId, + ruleId, + destinationId, + eventType, + payload, + scheduledAt, + deliveryKey, + attemptNumber, + }), + ) .onConflictDoNothing(), ) @@ -1361,135 +1363,204 @@ export class AlertsService extends Context.Service - db.run( - PG.from(AlertDeliveryEvents) - .select("destinationId") - .where(($) => [$.orgId.eq(orgId), $.incidentId.eq(incident.id)]), - ), - )).map((row) => row.destinationId) - : [] - const destinationIds = Arr.dedupe([...rule.destinationIds, ...priorDestinationIds]) - if (destinationIds.length === 0) return [] - - const rows = yield* dbExecute((db) => - db.run( - PG.from(AlertDestinations) - .select("id", "enabled") - .where(($) => [$.orgId.eq(orgId), $.id.in_(...destinationIds)]), - ), - ) - - const destinations = new Map(rows.map((row) => [row.id, row])) - - // Once per notification, not once per destination: N destinations - // on one rule must not mean N reads of the same series. Never - // fails — `loadChartSeries` answers `null` for every problem it - // can have, so a slow or broken warehouse costs the sparkline and - // nothing else. - const window = chartWindow({ - incidentStartedAtMs: incident.firstTriggeredAt, - nowMs: scheduledAt, - windowMinutes: rule.windowMinutes, - }) - // An uptime rule's series is a 0/1 step that says less than the check's error, so it gets no chart. - const series = - rule.signalType === "uptime" - ? null - : yield* loadChartSeries(warehouse, systemTenant(orgId), { - orgId, - ruleId: rule.id, - groupKey: incident.groupKey, - comparator: rule.comparator, - threshold: rule.threshold, - ...window, - }) + /** + * An incident event's delivery rows and the payload they carry. `withPayload` builds the + * payload even for a rule with no destinations, for an escalation chain to resend. + */ + const prepareIncidentNotification = Effect.fn("AlertsService.prepareIncidentNotification")( + function* ( + orgId: OrgId, + rule: NormalizedRule, + incident: AlertIncidentRow, + evaluation: EvaluatedRule, + eventType: AlertEventTypeValue, + scheduledAt: number, + withPayload: boolean, + ) { + // A resolve also reaches every destination this incident already + // notified, so a rule edit that drops one cannot orphan its alert. + const priorDestinationIds = + eventType === "resolve" + ? (yield* dbExecute((db) => + db.run( + PG.from(AlertDeliveryEvents) + .select("destinationId") + .where(($) => [$.orgId.eq(orgId), $.incidentId.eq(incident.id)]), + ), + )).map((row) => row.destinationId) + : [] + const destinationIds = Arr.dedupe([...rule.destinationIds, ...priorDestinationIds]) + if (destinationIds.length === 0 && !withPayload) return { deliveries: [], payload: null } + + const rows = + destinationIds.length === 0 + ? [] + : yield* dbExecute((db) => + db.run( + PG.from(AlertDestinations) + .select("id", "enabled") + .where(($) => [$.orgId.eq(orgId), $.id.in_(...destinationIds)]), + ), + ) - // The image URL rides with the series it describes: both pin the - // same window, so a retry an hour later renders the same picture - // this notification was written about. - const display = resolveSignalDisplay(rule) - const displayGroup = incident.groupKey === "__total__" ? null : incident.groupKey - const chartUrl = - series === null - ? null - : chartImageUrl({ - appBaseUrl: env.MAPLE_APP_BASE_URL, - hmacKey: Option.match(env.MAPLE_SHARE_TOKEN_HMAC_KEY, { - onNone: () => null, - onSome: (key) => Redacted.value(key), - }), - orgId, - ruleId: rule.id, - groupKey: incident.groupKey, - ...window, - title: displayGroup ? `${display.label} · ${displayGroup}` : display.label, - unit: mapSignalUnit(display.unit), - threshold: series.threshold, - breachSide: series.breachSide, - }) + const destinations = new Map(rows.map((row) => [row.id, row])) - const payload = yield* buildPayload( - { - eventType, - incidentId: incident.id, - incidentStatus: decodeAlertIncidentStatusSync(incident.status), - dedupeKey: incident.dedupeKey, - ruleId: rule.id, - ruleName: rule.name, - groupKey: incident.groupKey, - signalType: rule.signalType, - severity: rule.severity, - // Snapshotted with the event: a retry or a late delivery - // must page the key this event was raised under. - ruleSeverities: rule.thresholds.map((tier) => tier.severity), - ...(incident.peakSeverity === null - ? undefined - : { incidentPeakSeverity: decodeAlertSeveritySync(incident.peakSeverity) }), - comparator: rule.comparator, - threshold: rule.threshold, - thresholdUpper: rule.thresholdUpper, + // Once per notification, not once per destination: N destinations + // on one rule must not mean N reads of the same series. Never + // fails — `loadChartSeries` answers `null` for every problem it + // can have, so a slow or broken warehouse costs the sparkline and + // nothing else. + const window = chartWindow({ + incidentStartedAtMs: incident.firstTriggeredAt, + nowMs: scheduledAt, windowMinutes: rule.windowMinutes, - value: evaluation.value, - sampleCount: evaluation.sampleCount, - // The evaluator puts the check's error in the reason of an uptime rule. - detail: rule.signalType === "uptime" ? evaluation.reason : null, - sparkline: series?.sparkline ?? null, - chartUrl, - template: rule.notificationTemplate, - linkUrl: resolveNotificationLinkUrl(rule, incident.groupKey, incident.id), - sentAtMs: scheduledAt, - }, - orgId, - ).pipe(Effect.mapError((error) => makeValidationError(error.message, [], error))) + }) + // An uptime rule's series is a 0/1 step that says less than the check's error, so it gets no chart. + const series = + rule.signalType === "uptime" + ? null + : yield* loadChartSeries(warehouse, systemTenant(orgId), { + orgId, + ruleId: rule.id, + groupKey: incident.groupKey, + comparator: rule.comparator, + threshold: rule.threshold, + ...window, + }) - return destinationIds.flatMap((destinationId): ReadonlyArray => { - const destination = destinations.get(destinationId) - if (!destination || !destination.enabled) return [] - return [ + // The image URL rides with the series it describes: both pin the + // same window, so a retry an hour later renders the same picture + // this notification was written about. + const display = resolveSignalDisplay(rule) + const displayGroup = incident.groupKey === "__total__" ? null : incident.groupKey + const chartUrl = + series === null + ? null + : chartImageUrl({ + appBaseUrl: env.MAPLE_APP_BASE_URL, + hmacKey: Option.match(env.MAPLE_SHARE_TOKEN_HMAC_KEY, { + onNone: () => null, + onSome: (key) => Redacted.value(key), + }), + orgId, + ruleId: rule.id, + groupKey: incident.groupKey, + ...window, + title: displayGroup + ? `${display.label} · ${displayGroup}` + : display.label, + unit: mapSignalUnit(display.unit), + threshold: series.threshold, + breachSide: series.breachSide, + }) + + const payload = yield* buildPayload( { - orgId, + eventType, incidentId: incident.id, + incidentStatus: decodeAlertIncidentStatusSync(incident.status), + dedupeKey: incident.dedupeKey, ruleId: rule.id, - destinationId, - eventType, - payload, - scheduledAt, + ruleName: rule.name, + groupKey: incident.groupKey, + signalType: rule.signalType, + severity: rule.severity, + // Snapshotted with the event: a retry or a late delivery + // must page the key this event was raised under. + ruleSeverities: rule.thresholds.map((tier) => tier.severity), + ...(incident.peakSeverity === null + ? undefined + : { incidentPeakSeverity: decodeAlertSeveritySync(incident.peakSeverity) }), + comparator: rule.comparator, + threshold: rule.threshold, + thresholdUpper: rule.thresholdUpper, + windowMinutes: rule.windowMinutes, + value: evaluation.value, + sampleCount: evaluation.sampleCount, + // The evaluator puts the check's error in the reason of an uptime rule. + detail: rule.signalType === "uptime" ? evaluation.reason : null, + sparkline: series?.sparkline ?? null, + chartUrl, + template: rule.notificationTemplate, + linkUrl: resolveNotificationLinkUrl(rule, incident.groupKey, incident.id), + sentAtMs: scheduledAt, }, - ] - }) + orgId, + ).pipe(Effect.mapError((error) => makeValidationError(error.message, [], error))) + + const deliveries = destinationIds.flatMap( + (destinationId): ReadonlyArray => { + const destination = destinations.get(destinationId) + if (!destination || !destination.enabled) return [] + return [ + { + orgId, + incidentId: incident.id, + ruleId: rule.id, + destinationId, + eventType, + payload, + scheduledAt, + }, + ] + }, + ) + return { deliveries, payload } + }, + ) + + const prepareIncidentDeliveries = ( + orgId: OrgId, + rule: NormalizedRule, + incident: AlertIncidentRow, + evaluation: EvaluatedRule, + eventType: AlertEventTypeValue, + scheduledAt: number, + ) => + prepareIncidentNotification( + orgId, + rule, + incident, + evaluation, + eventType, + scheduledAt, + false, + ).pipe(Effect.map((notification) => notification.deliveries)) + + /** + * `chainId`'s chain for an incident at `severity`, with the trigger payload its steps resend. + * Null when there is no chain or it is gone. Read before the claim write. + */ + const prepareEscalationStart = Effect.fn("AlertsService.prepareEscalationStart")(function* ( + chainId: EscalationChainId | null, + orgId: OrgId, + rule: NormalizedRule, + severity: AlertSeverity, + incident: AlertIncidentRow, + evaluation: EvaluatedRule, + scheduledAt: number, + prepared: { + readonly deliveries: ReadonlyArray + readonly payload: Record | null + } | null, + ) { + if (chainId === null) return null + const chain = yield* dbExecute((db) => loadEscalationChain(db, orgId, chainId)) + if (chain === null) return null + const payload = + prepared?.payload ?? + (yield* prepareIncidentNotification( + orgId, + ruleAtSeverity(rule, severity), + incident, + evaluation, + "trigger", + scheduledAt, + true, + )).payload + if (payload === null) return null + yield* Effect.annotateCurrentSpan({ "maple.escalation.chain_id": chain.id }) + return { chain, payload, severity } }) const insertIncidentDeliveries = ( @@ -3154,18 +3225,34 @@ export class AlertsService extends Context.Service rows.length === 0 ? Effect.void - : insertIncidentDeliveries(db, deliveries), + : Effect.andThen( + insertIncidentDeliveries(db, deliveries), + escalation === null + ? Effect.void + : startEscalationRun(db, { + id: decodeAlertEscalationRunIdSync( + makeUuid(), + ), + orgId: row.orgId, + incidentId, + ...escalation, + nowMs: timestamp, + }), + ), ), ), ) @@ -3279,6 +3379,27 @@ export class AlertsService extends Context.Service tier.severity === severity, + ), + severity, + ), + row.orgId, + normalized, + severity, + refreshedIncident, + evaluation, + timestamp, + null, + ) + : null // An escalation clears whatever ack stands when it commits, decided in SQL rather // than from the prefetch, which a human ack or unack may have overtaken. const refreshed = yield* writeUnderClaim(claim, (db) => @@ -3311,6 +3432,15 @@ export class AlertsService extends Context.Service + enabledDestinationIds( + db, + row.orgId, + Arr.map(plan.step.targets, (target) => target.destinationId), + ), + ) + : new Set() + const outcome = yield* dbExecute((db) => + db.transaction( + applyEscalationPlan(db, { row, plan, nowMs, enabled, makeDeliveryId: makeUuid }), + ), + ) + yield* Effect.annotateCurrentSpan({ + "maple.escalation.plan": plan.kind, + "maple.escalation.outcome": outcome, + ...(plan.kind === "send" + ? { + "maple.escalation.step": plan.stepIndex + 1, + "maple.escalation.pass": plan.passIndex + 1, + "maple.escalation.lateness_ms": nowMs - row.nextStepAt, + "maple.escalation.targets": enabled.size, + } + : undefined), + }) + return outcome + }) + + /** Sends every due escalation step, so the drain that follows delivers it this tick. */ + const advanceEscalations = Effect.fn("AlertsService.advanceEscalations")(function* () { + const nowMs = yield* now + const work = yield* dbExecute((db) => selectEscalationWork(db, nowMs)) + const outcomes = yield* Effect.forEach( + work, + (row) => + advanceEscalationRun(row, nowMs).pipe( + Effect.catchCause((cause) => + Effect.logError("Escalation step failed").pipe( + Effect.annotateLogs({ + runId: row.id, + incidentId: row.incidentId, + cause: summarizeCause(cause), + }), + Effect.as("failed" as const), + ), + ), + ), + { concurrency: 4 }, + ) + const sent = Arr.filter(outcomes, (outcome) => outcome === "sent").length + yield* Effect.annotateCurrentSpan({ + "maple.escalation.runs": work.length, + "maple.escalation.sent": sent, + "maple.escalation.failed": Arr.filter(outcomes, (outcome) => outcome === "failed").length, + }) + return sent + }) + // Deliveries drain even when evaluation fails, so a broken rule pass // never holds back notifications already queued. The failure is raised // after the drain. @@ -5318,6 +5521,11 @@ export class AlertsService extends Context.Service Option.match(fiber, { onNone: () => Effect.succeed(0), onSome: Fiber.join }), ) + // The standard lane also advances escalation chains, after evaluation (which may have + // resolved an incident) and before the drain (which then sends the step this tick). + if (lane === "standard") { + yield* advanceEscalations().pipe(isolate("Escalation advance failed", {})) + } // One lane drains: the standard lane runs every minute, and its drain has its own 20s budget. const delivery = yield* Effect.exit( lane === "standard" diff --git a/packages/backend/src/services/alerts/EscalationChainsService.ts b/packages/backend/src/services/alerts/EscalationChainsService.ts new file mode 100644 index 0000000000..17183d25c9 --- /dev/null +++ b/packages/backend/src/services/alerts/EscalationChainsService.ts @@ -0,0 +1,366 @@ +import { + AlertForbiddenError, + AlertPersistenceError, + AlertRuleId as AlertRuleIdSchema, + AlertValidationError, + EscalationChainDocument, + EscalationChainId as EscalationChainIdSchema, + EscalationChainInUseError, + EscalationChainNotFoundError, + EscalationChainsListResponse, + IsoDateTimeString, + type AlertDestinationId, + type AlertRuleId, + type EscalationChainCreateRequest, + type EscalationChainId, + type EscalationChainUpdateRequest, + type EscalationSteps, + type OrgId, + type RoleName, + type UserId, +} from "@maple/domain/http" +import * as Orm from "@maple-dev/effect-orm/database" +import * as PG from "@maple-dev/effect-orm/postgres" +import { AlertDestinations, AlertRules, EscalationChains, type EscalationChainRow } from "@maple/db/tables" +import type { MapleDb } from "@maple/db/client" +import { Array as Arr, Context, Effect, Layer, Option, Result, Schema } from "effect" +import { Database } from "@maple/backend/platform/DatabaseLive" +import { makeDbExecute } from "@maple/backend/platform/db-execute" +import * as Policy from "@maple/domain/policy" +import { makePersistenceError } from "./alert-persistence" +import { AlertRuntime } from "./AlertRuntime" + +/** Chains an org may keep; a chain is reusable across rules, so few are needed. */ +const MAX_CHAINS_PER_ORG = 100 + +const decodeChainIdSync = Schema.decodeUnknownSync(EscalationChainIdSchema) +const decodeRuleIdSync = Schema.decodeUnknownSync(AlertRuleIdSchema) +// Only the field read here, leniently: a tier list in an older or newer shape still names its chains. +const decodeTierChainIds = Schema.decodeUnknownOption( + Schema.Array(Schema.Struct({ escalationChainId: Schema.optionalKey(Schema.NullOr(Schema.String)) })), +) + +export interface EscalationChainsServiceApi { + readonly list: (orgId: OrgId) => Effect.Effect + readonly get: ( + orgId: OrgId, + chainId: EscalationChainId, + ) => Effect.Effect + readonly create: ( + orgId: OrgId, + userId: UserId, + roles: ReadonlyArray, + request: EscalationChainCreateRequest, + ) => Effect.Effect< + EscalationChainDocument, + AlertForbiddenError | AlertValidationError | AlertPersistenceError + > + readonly update: ( + orgId: OrgId, + userId: UserId, + roles: ReadonlyArray, + chainId: EscalationChainId, + request: EscalationChainUpdateRequest, + ) => Effect.Effect< + EscalationChainDocument, + AlertForbiddenError | AlertValidationError | AlertPersistenceError | EscalationChainNotFoundError + > + /** Refused while a rule's tier names the chain; escalations already running finish from their snapshot. */ + readonly delete: ( + orgId: OrgId, + roles: ReadonlyArray, + chainId: EscalationChainId, + ) => Effect.Effect< + void, + AlertForbiddenError | AlertPersistenceError | EscalationChainNotFoundError | EscalationChainInUseError + > +} + +const validationError = (message: string) => new AlertValidationError({ message, details: [message] }) + +const decodeIsoSync = Schema.decodeUnknownSync(IsoDateTimeString) +const iso = (ms: number) => decodeIsoSync(new Date(ms).toISOString()) + +interface RuleRef { + readonly id: AlertRuleId + readonly name: string +} + +/** Which rules name each chain, from every rule's tier list. */ +const ruleRefsByChain = (db: MapleDb, orgId: OrgId) => + db + .run( + PG.from(AlertRules) + .select(($) => ({ id: $.id, name: $.name, thresholdsJson: PG.undecoded($.thresholdsJson) })) + .where(($) => [$.orgId.eq(orgId), $.thresholdsJson.isNotNull()]), + ) + .pipe( + Effect.map((rows) => { + const refs = new Map>() + Arr.forEach(rows, (row) => { + const chainIds = Option.match(decodeTierChainIds(row.thresholdsJson), { + onNone: () => [], + onSome: (tiers) => + Arr.dedupe( + Arr.filterMap(tiers, (tier) => + tier.escalationChainId == null + ? Result.failVoid + : Result.succeed(tier.escalationChainId), + ), + ), + }) + Arr.forEach(chainIds, (chainId) => { + refs.set(chainId, [ + ...(refs.get(chainId) ?? []), + { id: decodeRuleIdSync(row.id), name: row.name }, + ]) + }) + }) + return refs + }), + ) + +const stepDestinationIds = (steps: EscalationSteps): ReadonlyArray => + Arr.dedupe(Arr.flatMap(steps, (step) => Arr.map(step.targets, (target) => target.destinationId))) + +const toDocument = (row: EscalationChainRow, refs: ReadonlyArray) => + new EscalationChainDocument({ + id: row.id, + name: row.name, + description: row.description, + steps: row.stepsJson, + repeatCount: row.repeatCount, + ruleIds: Arr.map(refs, (ref) => ref.id), + createdAt: iso(row.createdAt), + updatedAt: iso(row.updatedAt), + }) + +export class EscalationChainsService extends Context.Service< + EscalationChainsService, + EscalationChainsServiceApi +>()("@maple/backend/services/alerts/EscalationChainsService", { + make: Effect.gen(function* () { + const database = yield* Database + const runtime = yield* AlertRuntime + const dbExecute = makeDbExecute(database, "EscalationChainsService", makePersistenceError) + + const requireManage = (roles: ReadonlyArray) => + Policy.check( + roles, + "alerts:manage", + (denial) => + new AlertForbiddenError({ + message: denial.message, + ...(roles.length > 0 ? { roles: [...roles] } : undefined), + }), + ) + + const findRow = (db: MapleDb, orgId: OrgId, chainId: EscalationChainId) => + db + .run( + PG.from(EscalationChains) + .select() + .where(($) => [$.orgId.eq(orgId), $.id.eq(chainId)]) + .limit(1), + ) + .pipe( + Effect.flatMap((rows) => + Arr.match(rows, { + onEmpty: () => + Effect.fail( + new EscalationChainNotFoundError({ + message: "Escalation chain not found", + chainId, + }), + ), + onNonEmpty: ([row]) => Effect.succeed(row), + }), + ), + ) + + /** Under the org's advisory lock, which destination deletion also takes before its reference scan. */ + const lockOrg = (db: MapleDb, orgId: OrgId) => + db.execute(Orm.sql`select pg_advisory_xact_lock(hashtext(${orgId}))`) + + const requireDestinations = (db: MapleDb, orgId: OrgId, steps: EscalationSteps) => + Effect.gen(function* () { + const ids = stepDestinationIds(steps) + const rows = yield* db.run( + PG.from(AlertDestinations) + .select("id") + .where(($) => [$.orgId.eq(orgId), $.id.in_(...ids)]), + ) + if (rows.length < ids.length) { + return yield* validationError( + "An escalation step names a destination that does not exist", + ) + } + }) + + const list = Effect.fn("EscalationChainsService.list")(function* (orgId: OrgId) { + const [rows, refs] = yield* dbExecute((db) => + Effect.all([ + db.run( + PG.from(EscalationChains) + .select() + .where(($) => [$.orgId.eq(orgId)]) + .orderBy(["name", "asc"]) + .limit(MAX_CHAINS_PER_ORG), + ), + ruleRefsByChain(db, orgId), + ]), + ) + return new EscalationChainsListResponse({ + chains: Arr.map(rows, (row) => toDocument(row, refs.get(row.id) ?? [])), + }) + }) + + const get = Effect.fn("EscalationChainsService.get")(function* ( + orgId: OrgId, + chainId: EscalationChainId, + ) { + const [row, refs] = yield* dbExecute((db) => + Effect.all([findRow(db, orgId, chainId), ruleRefsByChain(db, orgId)]), + ) + return toDocument(row, refs.get(row.id) ?? []) + }) + + const create = Effect.fn("EscalationChainsService.create")(function* ( + orgId: OrgId, + userId: UserId, + roles: ReadonlyArray, + request: EscalationChainCreateRequest, + ) { + yield* requireManage(roles) + const nowMs = yield* runtime.now + const id = decodeChainIdSync(runtime.makeUuid()) + const row = yield* dbExecute((db) => + db.transaction( + Effect.gen(function* () { + yield* lockOrg(db, orgId) + const existing = yield* db.run( + PG.from(EscalationChains) + .select("id") + .where(($) => [$.orgId.eq(orgId)]) + .limit(MAX_CHAINS_PER_ORG), + ) + if (existing.length >= MAX_CHAINS_PER_ORG) { + return yield* validationError( + `Organizations may have at most ${MAX_CHAINS_PER_ORG} escalation chains`, + ) + } + yield* requireDestinations(db, orgId, request.steps) + const [inserted] = yield* db.run( + PG.insertInto(EscalationChains) + .values({ + id, + orgId, + name: request.name, + description: request.description ?? null, + stepsJson: request.steps, + repeatCount: request.repeatCount ?? 0, + createdAt: nowMs, + updatedAt: nowMs, + createdBy: userId, + updatedBy: userId, + }) + .returning(), + ) + return inserted + }), + ), + ) + if (row === undefined) { + return yield* new AlertPersistenceError({ message: "Escalation chain was not created" }) + } + return toDocument(row, []) + }) + + const update = Effect.fn("EscalationChainsService.update")(function* ( + orgId: OrgId, + userId: UserId, + roles: ReadonlyArray, + chainId: EscalationChainId, + request: EscalationChainUpdateRequest, + ) { + yield* requireManage(roles) + const nowMs = yield* runtime.now + const [row, refs] = yield* dbExecute((db) => + db.transaction( + Effect.gen(function* () { + yield* lockOrg(db, orgId) + yield* findRow(db, orgId, chainId) + if (request.steps !== undefined) yield* requireDestinations(db, orgId, request.steps) + const [updated] = yield* db.run( + PG.update(EscalationChains) + .set({ + ...(request.name === undefined ? undefined : { name: request.name }), + ...(request.description === undefined + ? undefined + : { description: request.description }), + ...(request.steps === undefined + ? undefined + : { stepsJson: request.steps }), + ...(request.repeatCount === undefined + ? undefined + : { repeatCount: request.repeatCount }), + updatedAt: nowMs, + updatedBy: userId, + }) + .where(($) => [$.orgId.eq(orgId), $.id.eq(chainId)]) + .returning(), + ) + if (updated === undefined) { + return yield* new EscalationChainNotFoundError({ + message: "Escalation chain not found", + chainId, + }) + } + return [updated, yield* ruleRefsByChain(db, orgId)] as const + }), + ), + ) + return toDocument(row, refs.get(row.id) ?? []) + }) + + const remove = Effect.fn("EscalationChainsService.delete")(function* ( + orgId: OrgId, + roles: ReadonlyArray, + chainId: EscalationChainId, + ) { + yield* requireManage(roles) + yield* dbExecute((db) => + db.transaction( + Effect.gen(function* () { + // Rule writes check their chains under this lock, so no rule can commit a reference + // between this scan and the delete. + yield* lockOrg(db, orgId) + yield* findRow(db, orgId, chainId) + const refs = (yield* ruleRefsByChain(db, orgId)).get(chainId) ?? [] + if (refs.length > 0) { + return yield* new EscalationChainInUseError({ + message: `Escalation chain is still used by alert rules: ${Arr.join( + Arr.map(refs, (ref) => ref.name), + ", ", + )}`, + chainId, + ruleIds: Arr.map(refs, (ref) => ref.id), + ruleNames: Arr.map(refs, (ref) => ref.name), + }) + } + yield* db.run( + PG.deleteFrom(EscalationChains).where(($) => [ + $.orgId.eq(orgId), + $.id.eq(chainId), + ]), + ) + }), + ), + ) + }) + + return { list, get, create, update, delete: remove } satisfies EscalationChainsServiceApi + }), +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend/src/services/alerts/alert-delivery-rows.ts b/packages/backend/src/services/alerts/alert-delivery-rows.ts new file mode 100644 index 0000000000..a979019f65 --- /dev/null +++ b/packages/backend/src/services/alerts/alert-delivery-rows.ts @@ -0,0 +1,50 @@ +import type { + AlertDestinationId, + AlertEventType, + AlertIncidentId, + AlertRuleId, + OrgId, +} from "@maple/domain/http" +import { AlertDeliveryEventDocument } from "@maple/domain/http" +import type { AlertDeliveryEventRow } from "@maple/db/tables" +import { Schema } from "effect" + +const decodeDeliveryEventId = Schema.decodeUnknownSync(AlertDeliveryEventDocument.fields.id) + +export interface QueuedDeliveryEvent { + readonly id: string + readonly orgId: OrgId + readonly incidentId: AlertIncidentId | null + readonly ruleId: AlertRuleId + readonly destinationId: AlertDestinationId + readonly eventType: AlertEventType + readonly payload: Record + readonly scheduledAt: number + readonly deliveryKey: string + readonly attemptNumber: number +} + +/** A delivery row waiting for the drain; every writer of the outbox builds its row here. */ +export const queuedDeliveryEventRow = (event: QueuedDeliveryEvent): AlertDeliveryEventRow => ({ + id: decodeDeliveryEventId(event.id), + orgId: event.orgId, + incidentId: event.incidentId, + ruleId: event.ruleId, + destinationId: event.destinationId, + deliveryKey: event.deliveryKey, + eventType: event.eventType, + attemptNumber: event.attemptNumber, + status: "queued", + scheduledAt: event.scheduledAt, + claimedAt: null, + claimExpiresAt: null, + claimedBy: null, + attemptedAt: null, + providerMessage: null, + providerReference: null, + responseCode: null, + errorMessage: null, + payloadJson: event.payload, + createdAt: event.scheduledAt, + updatedAt: event.scheduledAt, +}) diff --git a/packages/backend/src/services/alerts/alert-escalation-runs.ts b/packages/backend/src/services/alerts/alert-escalation-runs.ts new file mode 100644 index 0000000000..5254c955a6 --- /dev/null +++ b/packages/backend/src/services/alerts/alert-escalation-runs.ts @@ -0,0 +1,293 @@ +/** + * Escalation runs: one incident's walk through a chain. The alert scheduler starts a run in the + * transaction that opens or escalates an incident, and advances due runs between evaluation and + * the delivery drain, so a due step is sent in the same tick. Each step is a compare-and-set on the + * run's position committed with the step's delivery rows: at most one writer sends a given step. + */ +import { makeAlertDeliveryKey, planEscalationAdvance, type EscalationAdvancePlan } from "@maple/alerting-core" +import type { + AlertDestinationId, + AlertIncidentId, + AlertSeverity, + EscalationChainId, + EscalationSteps, + OrgId, +} from "@maple/domain/http" +import type { AlertEscalationRunId } from "@maple/domain/primitives" +import * as PG from "@maple-dev/effect-orm/postgres" +import { + AlertDeliveryEvents, + AlertDestinations, + AlertEscalationRuns, + AlertIncidents, + EscalationChains, +} from "@maple/db/tables" +import type { MapleDb } from "@maple/db/client" +import { Array as Arr, Effect } from "effect" +import { queuedDeliveryEventRow } from "./alert-delivery-rows" + +const LIVE_STATUSES = ["active", "acknowledged"] as const + +/** Runs one scheduler tick looks at; the rest wait a minute, oldest due first. */ +export const ESCALATION_RUNS_PER_TICK = 200 + +export interface EscalationChainSnapshot { + readonly id: EscalationChainId + readonly name: string + readonly steps: EscalationSteps + readonly repeatCount: number +} + +/** The chain an org's tier names, or null when it was deleted since the rule was saved. */ +export const loadEscalationChain = (db: MapleDb, orgId: OrgId, chainId: EscalationChainId) => + db + .run( + PG.from(EscalationChains) + .select("id", "name", "stepsJson", "repeatCount") + .where(($) => [$.orgId.eq(orgId), $.id.eq(chainId)]) + .limit(1), + ) + .pipe( + Effect.map((rows) => + Arr.match(rows, { + onEmpty: (): EscalationChainSnapshot | null => null, + onNonEmpty: ([row]) => ({ + id: row.id, + name: row.name, + steps: row.stepsJson, + repeatCount: row.repeatCount, + }), + }), + ), + ) + +export interface StartEscalationRunInput { + readonly id: AlertEscalationRunId + readonly orgId: OrgId + readonly incidentId: AlertIncidentId + readonly chain: EscalationChainSnapshot + readonly severity: AlertSeverity + /** The incident's trigger notification; each step resends it to the step's targets. */ + readonly payload: Record + readonly nowMs: number +} + +/** + * Starts `chain` for an incident, superseding the run already live on it. The first step is due + * now, and its delivery key matches the incident's own trigger, so a destination both in the tier + * and in step 1 is notified once. + */ +export const startEscalationRun = (db: MapleDb, input: StartEscalationRunInput) => + Effect.gen(function* () { + yield* db.run( + PG.update(AlertEscalationRuns) + .set({ status: "superseded", endedAt: input.nowMs, updatedAt: input.nowMs }) + .where(($) => [ + $.orgId.eq(input.orgId), + $.incidentId.eq(input.incidentId), + $.status.in_(...LIVE_STATUSES), + ]), + ) + yield* db.run( + PG.insertInto(AlertEscalationRuns).values({ + id: input.id, + orgId: input.orgId, + incidentId: input.incidentId, + chainId: input.chain.id, + chainName: input.chain.name, + severity: input.severity, + stepsJson: input.chain.steps, + repeatCount: input.chain.repeatCount, + stepIndex: 0, + passIndex: 0, + nextStepAt: input.nowMs, + status: "active", + payloadJson: input.payload, + stepsSent: 0, + lastStepAt: null, + endedAt: null, + createdAt: input.nowMs, + updatedAt: input.nowMs, + }), + ) + }) + +/** + * Live runs with something to decide: due, paused on an ack that may be gone, or on an incident + * that is no longer open. Across orgs: the scheduler is the system actor. + */ +export const selectEscalationWork = (db: MapleDb, nowMs: number) => + db.run( + PG.from(AlertEscalationRuns, "r") + .innerJoin(AlertIncidents, "i", (r, i) => r.incidentId.eq(i.id).and(r.orgId.eq(i.orgId))) + .select(($) => ({ + id: $.id, + orgId: $.orgId, + incidentId: $.incidentId, + chainId: $.chainId, + chainName: $.chainName, + status: $.status, + stepsJson: $.stepsJson, + repeatCount: $.repeatCount, + stepIndex: $.stepIndex, + passIndex: $.passIndex, + nextStepAt: $.nextStepAt, + payloadJson: $.payloadJson, + stepsSent: $.stepsSent, + ruleId: $.i.ruleId, + groupKey: $.i.groupKey, + severity: $.i.severity, + incidentStatus: $.i.status, + acknowledgedAt: $.i.acknowledgedAt, + })) + .where(($) => [ + $.status.in_(...LIVE_STATUSES), + PG.or($.nextStepAt.lte(nowMs), $.status.eq("acknowledged"), $.i.status.neq("open")), + ]) + .orderBy(["nextStepAt", "asc"]) + .limit(ESCALATION_RUNS_PER_TICK), + ) + +export type EscalationWorkRow = Effect.Success>[number] + +export const planEscalationWork = (row: EscalationWorkRow, silenced: boolean, nowMs: number) => + planEscalationAdvance( + { + status: row.status, + steps: row.stepsJson, + repeatCount: row.repeatCount, + stepIndex: row.stepIndex, + passIndex: row.passIndex, + nextStepAt: row.nextStepAt, + }, + { open: row.incidentStatus === "open", acknowledged: row.acknowledgedAt !== null, silenced }, + nowMs, + ) + +/** The enabled destinations among `ids` in `orgId`; a deleted or disabled one is skipped. */ +export const enabledDestinationIds = (db: MapleDb, orgId: OrgId, ids: ReadonlyArray) => + ids.length === 0 + ? Effect.succeed(new Set()) + : db + .run( + PG.from(AlertDestinations) + .select("id") + .where(($) => [$.orgId.eq(orgId), $.id.in_(...ids), $.enabled.eq(true)]), + ) + .pipe(Effect.map((rows) => new Set(Arr.map(rows, (row) => row.id)))) + +export type EscalationApplyOutcome = "sent" | "ended" | "paused" | "resumed" | "deferred" | "lost" | "idle" + +type RunUpdate = Partial> +type EscalationRunInsert = PG.InsertRowOf + +/** Moves the run only from the position it was read at, so of two concurrent writers one wins. */ +const atPosition = (row: EscalationWorkRow, set: RunUpdate) => + PG.update(AlertEscalationRuns) + .set(set) + .where(($) => [ + $.id.eq(row.id), + $.status.eq(row.status), + $.stepIndex.eq(row.stepIndex), + $.passIndex.eq(row.passIndex), + $.nextStepAt.eq(row.nextStepAt), + ]) + .returning("id") + +export interface ApplyEscalationPlanInput { + readonly row: EscalationWorkRow + readonly plan: EscalationAdvancePlan + readonly nowMs: number + /** Of the step's destinations, those enabled; only needed for a `send`. */ + readonly enabled: ReadonlySet + readonly makeDeliveryId: () => string +} + +/** Applies one plan; run inside a transaction so a step's position and deliveries commit together. */ +export const applyEscalationPlan = (db: MapleDb, input: ApplyEscalationPlanInput) => + Effect.gen(function* () { + const { row, plan, nowMs } = input + const write = (set: RunUpdate) => + db.run(atPosition(row, { ...set, updatedAt: nowMs })).pipe(Effect.map((rows) => rows.length > 0)) + switch (plan.kind) { + case "wait": + return "idle" satisfies EscalationApplyOutcome + case "end": + return (yield* write({ status: plan.status, endedAt: nowMs })) ? "ended" : "lost" + case "pause": + return (yield* write({ status: "acknowledged" })) ? "paused" : "lost" + case "resume": + return (yield* write({ status: "active" })) ? "resumed" : "lost" + case "defer": + return (yield* write({ status: "active", nextStepAt: plan.nextStepAt })) ? "deferred" : "lost" + case "send": { + const moved = yield* write( + plan.next.kind === "exhausted" + ? { + status: "exhausted", + endedAt: nowMs, + stepsSent: row.stepsSent + 1, + lastStepAt: nowMs, + } + : { + status: "active", + stepIndex: plan.next.stepIndex, + passIndex: plan.next.passIndex, + nextStepAt: plan.next.nextStepAt, + stepsSent: row.stepsSent + 1, + lastStepAt: nowMs, + }, + ) + if (!moved) return "lost" satisfies EscalationApplyOutcome + const payload = { + ...row.payloadJson, + sentAt: new Date(nowMs).toISOString(), + escalation: { + chainId: row.chainId, + chainName: row.chainName, + step: plan.stepIndex + 1, + stepCount: row.stepsJson.length, + pass: plan.passIndex + 1, + passCount: row.repeatCount + 1, + }, + } + const destinationIds = Arr.dedupe( + Arr.filter( + Arr.map(plan.step.targets, (target) => target.destinationId), + (id) => input.enabled.has(id), + ), + ) + yield* Effect.forEach( + destinationIds, + (destinationId) => + db.run( + PG.insertInto(AlertDeliveryEvents) + .values( + queuedDeliveryEventRow({ + id: input.makeDeliveryId(), + orgId: row.orgId, + incidentId: row.incidentId, + ruleId: row.ruleId, + destinationId, + eventType: "trigger", + payload, + scheduledAt: nowMs, + // Keyed by the step's due time: the step a tier trigger shares a + // destination with dedupes against it, and no two steps share one. + deliveryKey: makeAlertDeliveryKey( + row.incidentId, + destinationId, + "trigger", + row.nextStepAt, + ), + attemptNumber: 1, + }), + ) + .onConflictDoNothing(), + ), + { discard: true }, + ) + return "sent" satisfies EscalationApplyOutcome + } + } + }) diff --git a/packages/backend/src/services/alerts/alert-thresholds.ts b/packages/backend/src/services/alerts/alert-thresholds.ts index c2b6c430a5..e635d5c788 100644 --- a/packages/backend/src/services/alerts/alert-thresholds.ts +++ b/packages/backend/src/services/alerts/alert-thresholds.ts @@ -1,6 +1,7 @@ import { compareAlertThreshold, maxSeverity, tierFor } from "@maple/alerting-core" import { ALERT_SEVERITY_RANK, + alertSeverityOrder, isRangeComparator, MAX_ALERT_BREACH_WINDOW, sortAlertThresholds, @@ -9,8 +10,9 @@ import { type AlertSeverity, type AlertThreshold, type AlertThresholds, + type EscalationChainId, } from "@maple/domain/http" -import { Array as Arr, Schema } from "effect" +import { Array as Arr, Option, Schema } from "effect" const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)) @@ -29,6 +31,27 @@ const destinationsUpTo = ( ), ) +/** + * The chain an incident at `severity` starts: its tier's, else the closest lower tier's, as a + * critical incident also reaches the warning tier's destinations. Null when none names one. + */ +export const escalationChainFor = ( + thresholds: ReadonlyArray, + severity: AlertSeverity, +): EscalationChainId | null => { + const candidates = Arr.filter( + thresholds, + (tier) => + ALERT_SEVERITY_RANK[tier.severity] <= ALERT_SEVERITY_RANK[severity] && + tier.escalationChainId !== undefined && + tier.escalationChainId !== null, + ) + return Option.match(Arr.last(Arr.sort(candidates, alertSeverityOrder)), { + onNone: () => null, + onSome: (tier) => tier.escalationChainId ?? null, + }) +} + interface TieredRuleFields { readonly thresholds: AlertThresholds readonly severity: AlertSeverity diff --git a/packages/backend/src/services/org/OrganizationService.ts b/packages/backend/src/services/org/OrganizationService.ts index b430f8d765..ffc51fd995 100644 --- a/packages/backend/src/services/org/OrganizationService.ts +++ b/packages/backend/src/services/org/OrganizationService.ts @@ -30,6 +30,7 @@ import { Actors, AgentFeedback, AlertDeliveryEvents, + AlertEscalationRuns, AlertIncidentEvents, AlertDestinations, AlertIncidents, @@ -38,6 +39,7 @@ import { AlertRules, ApiKeys, CancellationReviews, + EscalationChains, ChatIdentities, ChatWorkspaces, CliDeviceAuthorizations, @@ -142,7 +144,9 @@ const ORG_SCOPED_TABLES: ReadonlyArray = [ AlertDeliveryEvents, // Before its incident: an event row references it. AlertIncidentEvents, + AlertEscalationRuns, AlertIncidents, + EscalationChains, AlertRuleStates, AlertRules, // Before its destination: a thread row references it. diff --git a/packages/db/drizzle/20261106113702_escalation_chains/migration.sql b/packages/db/drizzle/20261106113702_escalation_chains/migration.sql new file mode 100644 index 0000000000..b5e0e2e2e4 --- /dev/null +++ b/packages/db/drizzle/20261106113702_escalation_chains/migration.sql @@ -0,0 +1,52 @@ +-- metadata +CREATE TABLE IF NOT EXISTS "alert_escalation_runs" ( + "id" text NOT NULL, + "org_id" text NOT NULL, + "incident_id" text NOT NULL, + "chain_id" text NOT NULL, + "chain_name" text NOT NULL, + "severity" text NOT NULL, + "steps_json" jsonb NOT NULL, + "repeat_count" integer NOT NULL, + "step_index" integer NOT NULL DEFAULT 0, + "pass_index" integer NOT NULL DEFAULT 0, + "next_step_at" timestamp with time zone NOT NULL, + "status" text NOT NULL, + "payload_json" jsonb NOT NULL, + "steps_sent" integer NOT NULL DEFAULT 0, + "last_step_at" timestamp with time zone, + "ended_at" timestamp with time zone, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + CONSTRAINT "alert_escalation_runs_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +-- metadata +CREATE TABLE IF NOT EXISTS "escalation_chains" ( + "id" text NOT NULL, + "org_id" text NOT NULL, + "name" text NOT NULL, + "description" text, + "steps_json" jsonb NOT NULL, + "repeat_count" integer NOT NULL DEFAULT 0, + "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 "escalation_chains_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +-- metadata +CREATE INDEX IF NOT EXISTS "alert_escalation_runs_live_idx" ON "alert_escalation_runs" USING btree ("status", "next_step_at") WHERE "status" in ('active', 'acknowledged'); +--> statement-breakpoint +-- metadata +CREATE UNIQUE INDEX IF NOT EXISTS "alert_escalation_runs_live_incident_idx" ON "alert_escalation_runs" USING btree ("incident_id") WHERE "status" IN ('active', 'acknowledged'); +--> statement-breakpoint +-- metadata +CREATE INDEX IF NOT EXISTS "alert_escalation_runs_org_incident_idx" ON "alert_escalation_runs" USING btree ("org_id", "incident_id"); +--> statement-breakpoint +-- metadata +CREATE INDEX IF NOT EXISTS "escalation_chains_org_idx" ON "escalation_chains" USING btree ("org_id"); +--> statement-breakpoint +-- metadata +ALTER TABLE "alert_escalation_runs" ADD CONSTRAINT "alert_escalation_runs_incident_id_alert_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "alert_incidents" ("id") ON DELETE CASCADE; diff --git a/packages/db/drizzle/20261106113702_escalation_chains/snapshot.json b/packages/db/drizzle/20261106113702_escalation_chains/snapshot.json new file mode 100644 index 0000000000..653325bf59 --- /dev/null +++ b/packages/db/drizzle/20261106113702_escalation_chains/snapshot.json @@ -0,0 +1,19340 @@ +{ + "version": "1", + "dialect": "postgres", + "id": "82f424c799908655a6841b74922baf95292238868d3334b18eecdfb160b1c73b", + "prevIds": [ + "735b86449ab62a2a8b8163dbb4278bca8e991f0dfafc0f3b7c2418ea6b2446b9" + ], + "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_escalation_runs", + "primaryKey": { + "name": "alert_escalation_runs_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_incident_events", + "primaryKey": { + "name": "alert_incident_events_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": "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": "escalation_chains", + "primaryKey": { + "name": "escalation_chains_pkey", + "columns": [ + "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": "payload_json", + "position": 18, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "created_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "updated_at", + "position": 20, + "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_escalation_runs", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "incident_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "chain_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "chain_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "severity", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "steps_json", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "repeat_count", + "position": 7, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "step_index", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "pass_index", + "position": 9, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "next_step_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "status", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "payload_json", + "position": 12, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "steps_sent", + "position": 13, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "last_step_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "ended_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "created_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "updated_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "incident_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "actor_user_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "actor_name", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "source", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "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": "acknowledged_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "acknowledged_by_user_id", + "position": 25, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "acknowledged_by_name", + "position": 26, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "acknowledged_via", + "position": 27, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "error_issue_id", + "position": 28, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "created_at", + "position": 29, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "updated_at", + "position": 30, + "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": "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": "escalation_chains", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "description", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "steps_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "repeat_count", + "position": 5, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "updated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "created_by", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "updated_by", + "position": 9, + "type": "text", + "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_escalation_runs", + "name": "alert_escalation_runs_live_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"next_step_at\"" + ], + "where": "\"status\" in ('active', 'acknowledged')" + }, + { + "kind": "index", + "table": "alert_escalation_runs", + "name": "alert_escalation_runs_live_incident_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"incident_id\"" + ], + "where": "\"status\" IN ('active', 'acknowledged')" + }, + { + "kind": "index", + "table": "alert_escalation_runs", + "name": "alert_escalation_runs_org_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incident_events", + "name": "alert_incident_events_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_id\"", + "\"created_at\"" + ], + "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": "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": "escalation_chains", + "name": "escalation_chains_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "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_escalation_runs", + "name": "alert_escalation_runs_incident_id_alert_incidents_id_fk", + "columns": [ + "incident_id" + ], + "foreignTable": "alert_incidents", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "alert_incident_events", + "name": "alert_incident_events_incident_id_alert_incidents_id_fk", + "columns": [ + "incident_id" + ], + "foreignTable": "alert_incidents", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "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/escalation-chains.ts b/packages/db/src/tables/escalation-chains.ts new file mode 100644 index 0000000000..c4ce31df5d --- /dev/null +++ b/packages/db/src/tables/escalation-chains.ts @@ -0,0 +1,88 @@ +import * as PG from "@maple-dev/effect-orm/postgres" +import { AlertEscalationRunStatus, AlertSeverity, EscalationSteps } from "@maple/domain/http" +import { AlertEscalationRunId, AlertIncidentId, EscalationChainId, OrgId } from "@maple/domain/primitives" +import { Schema } from "effect" +import { AlertIncidents } from "./alerts" + +/** + * Ordered notification steps a severity tier can start: notify, wait for an ack, then the next + * step, the whole chain repeating `repeat_count` more times. Distinct from `issue_escalation_policies`, + * which routes a triage severity once and has no timer. + */ +export const EscalationChains = PG.table("escalation_chains", { + columns: { + id: PG.brand(PG.text, EscalationChainId), + orgId: PG.column(PG.brand(PG.text, OrgId), { name: "org_id" }), + name: PG.text, + description: PG.nullable(PG.text), + stepsJson: PG.column(PG.jsonb(EscalationSteps), { name: "steps_json" }), + repeatCount: PG.column(PG.int4, { name: "repeat_count", default: 0 }), + 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("escalation_chains_org_idx", ["orgId"])], + tenantColumn: "orgId", +}) + +const alertEscalationRunsColumns = { + id: PG.brand(PG.text, AlertEscalationRunId), + orgId: PG.column(PG.brand(PG.text, OrgId), { name: "org_id" }), + incidentId: PG.column(PG.brand(PG.text, AlertIncidentId), { name: "incident_id" }), + /** No foreign key: a deleted chain's running escalations finish from their snapshot. */ + chainId: PG.column(PG.brand(PG.text, EscalationChainId), { name: "chain_id" }), + chainName: PG.column(PG.text, { name: "chain_name" }), + /** The tier whose chain this is. */ + severity: PG.brand(PG.text, AlertSeverity), + /** The chain as it stood when the run started, so an edit cannot shift a page in flight. */ + stepsJson: PG.column(PG.jsonb(EscalationSteps), { name: "steps_json" }), + repeatCount: PG.column(PG.int4, { name: "repeat_count" }), + /** The next step to send and the pass it belongs to, both from 0. */ + stepIndex: PG.column(PG.int4, { name: "step_index", default: 0 }), + passIndex: PG.column(PG.int4, { name: "pass_index", default: 0 }), + nextStepAt: PG.column(PG.timestamptzMillis, { name: "next_step_at" }), + status: PG.brand(PG.text, AlertEscalationRunStatus), + /** The trigger notification the run opened with; every step resends it. */ + payloadJson: PG.column(PG.jsonb(Schema.Record(Schema.String, Schema.Unknown)), { name: "payload_json" }), + stepsSent: PG.column(PG.int4, { name: "steps_sent", default: 0 }), + lastStepAt: PG.column(PG.nullable(PG.timestamptzMillis), { name: "last_step_at" }), + endedAt: PG.column(PG.nullable(PG.timestamptzMillis), { name: "ended_at" }), + createdAt: PG.column(PG.timestamptzMillis, { name: "created_at" }), + updatedAt: PG.column(PG.timestamptzMillis, { name: "updated_at" }), +} +type AlertEscalationRunsColumns = PG.ColumnsOf + +/** One incident's walk through a chain. The scheduler advances it one step at a time. */ +export const AlertEscalationRuns = PG.table("alert_escalation_runs", { + columns: alertEscalationRunsColumns, + primaryKey: ["id"], + indexes: [ + PG.index("alert_escalation_runs_org_incident_idx", ["orgId", "incidentId"]), + // The scheduler's scan: live runs only, which are a small slice of the table. + PG.index("alert_escalation_runs_live_idx", ["status", "nextStepAt"], { + where: `"status" in ('active', 'acknowledged')`, + }), + // At most one live run per incident: a newer tier's chain supersedes the old one. + PG.uniqueIndex( + "alert_escalation_runs_live_incident_idx", + ["incidentId"], + { + where: ($) => $.status.in_("active", "acknowledged"), + }, + ), + ], + foreignKeys: [ + PG.foreignKey({ + columns: ["incidentId"], + references: AlertIncidents, + foreignColumns: ["id"], + onDelete: "cascade", + }), + ], + tenantColumn: "orgId", +}) + +export type EscalationChainRow = PG.SelectRowOf +export type AlertEscalationRunRow = PG.SelectRowOf diff --git a/packages/db/src/tables/index.ts b/packages/db/src/tables/index.ts index ac85205803..43c52f1687 100644 --- a/packages/db/src/tables/index.ts +++ b/packages/db/src/tables/index.ts @@ -41,6 +41,7 @@ import { ErrorIssueVerifications, } from "./errors" import { IssueEscalationPolicies, IssueEscalations } from "./escalations" +import { AlertEscalationRuns, EscalationChains } from "./escalation-chains" import { AwsConnections } from "./aws-connections" import { GcpConnectors } from "./gcp-connectors" import { GcpResources } from "./gcp-resources" @@ -125,6 +126,7 @@ export * from "./device-credentials" export * from "./digest" export * from "./errors" export * from "./escalations" +export * from "./escalation-chains" export * from "./aws-connections" export * from "./gcp-connectors" export * from "./gcp-resources" @@ -198,6 +200,8 @@ export const allTables: ReadonlyArray> = [ ErrorIssueVerifications, IssueEscalationPolicies, IssueEscalations, + EscalationChains, + AlertEscalationRuns, GcpConnectors, GcpResources, AwsConnections, diff --git a/packages/domain/src/http/alerts.ts b/packages/domain/src/http/alerts.ts index e9b66f54ba..850faa9440 100644 --- a/packages/domain/src/http/alerts.ts +++ b/packages/domain/src/http/alerts.ts @@ -9,6 +9,7 @@ import { ChatConnectorId, ChatWorkspaceId, ErrorIssueId, + EscalationChainId, IsoDateTimeString, PostgresTransactionId, RoleName, @@ -245,6 +246,8 @@ export const AlertThreshold = Schema.Struct({ /** Null recovers as soon as the trigger stops holding. */ recoveryValue: Schema.NullOr(Schema.Number), destinationIds: Schema.Array(AlertDestinationId), + /** Escalation chain an incident at this tier starts, on top of its destinations. */ + escalationChainId: Schema.optionalKey(Schema.NullOr(EscalationChainId)), }).annotate({ identifier: "@maple/AlertThreshold", title: "Alert Threshold" }) export type AlertThreshold = Schema.Schema.Type diff --git a/packages/domain/src/http/escalation-chains.ts b/packages/domain/src/http/escalation-chains.ts new file mode 100644 index 0000000000..177f617781 --- /dev/null +++ b/packages/domain/src/http/escalation-chains.ts @@ -0,0 +1,128 @@ +import { Schema } from "effect" +import { AlertDestinationId, AlertRuleId, EscalationChainId, IsoDateTimeString } from "../primitives" +import { HttpTaggedError } from "./error-policy" + +export const MAX_ESCALATION_STEPS = 10 +export const MAX_ESCALATION_STEP_TARGETS = 10 +export const MAX_ESCALATION_REPEATS = 5 +/** A day: a longer wait is a schedule, not an escalation. */ +export const MAX_ESCALATION_WAIT_MINUTES = 1440 + +/** Who a step notifies. */ +export const EscalationTarget = Schema.Struct({ + type: Schema.Literal("destination"), + destinationId: AlertDestinationId, +}).annotate({ identifier: "@maple/EscalationTarget", title: "Escalation Target" }) +export type EscalationTarget = Schema.Schema.Type + +export const EscalationWaitMinutes = Schema.Int.check( + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(MAX_ESCALATION_WAIT_MINUTES), +) + +/** Notify every target, then wait `waitMinutes` for an acknowledgement before the next step. */ +export const EscalationStep = Schema.Struct({ + targets: Schema.NonEmptyArray(EscalationTarget).check(Schema.isMaxLength(MAX_ESCALATION_STEP_TARGETS)), + waitMinutes: EscalationWaitMinutes, +}).annotate({ identifier: "@maple/EscalationStep", title: "Escalation Step" }) +export type EscalationStep = Schema.Schema.Type + +export const EscalationSteps = Schema.NonEmptyArray(EscalationStep) + .check(Schema.isMaxLength(MAX_ESCALATION_STEPS)) + .annotate({ identifier: "@maple/EscalationSteps", title: "Escalation Steps" }) +export type EscalationSteps = Schema.Schema.Type + +/** Extra passes over the whole chain after the first; 0 runs it once. */ +export const EscalationRepeatCount = Schema.Int.check( + Schema.isGreaterThanOrEqualTo(0), + Schema.isLessThanOrEqualTo(MAX_ESCALATION_REPEATS), +) + +export const EscalationChainName = Schema.String.check( + Schema.isMinLength(1), + Schema.isTrimmed(), + Schema.isMaxLength(120), +) +export const EscalationChainDescription = Schema.String.check(Schema.isMaxLength(500)) + +export class EscalationChainDocument extends Schema.Class("EscalationChainDocument")( + { + id: EscalationChainId, + name: Schema.String, + description: Schema.NullOr(Schema.String), + steps: EscalationSteps, + repeatCount: Schema.Number, + /** Rules with a tier that starts this chain. */ + ruleIds: Schema.Array(AlertRuleId), + createdAt: IsoDateTimeString, + updatedAt: IsoDateTimeString, + }, +) {} + +export class EscalationChainsListResponse extends Schema.Class( + "EscalationChainsListResponse", +)({ + chains: Schema.Array(EscalationChainDocument), +}) {} + +export class EscalationChainCreateRequest extends Schema.Class( + "EscalationChainCreateRequest", +)({ + name: EscalationChainName, + description: Schema.optionalKey(Schema.NullOr(EscalationChainDescription)), + steps: EscalationSteps, + repeatCount: Schema.optionalKey(EscalationRepeatCount), +}) {} + +export class EscalationChainUpdateRequest extends Schema.Class( + "EscalationChainUpdateRequest", +)({ + name: Schema.optionalKey(EscalationChainName), + description: Schema.optionalKey(Schema.NullOr(EscalationChainDescription)), + steps: Schema.optionalKey(EscalationSteps), + repeatCount: Schema.optionalKey(EscalationRepeatCount), +}) {} + +/** How far an incident's escalation got. `active` waits on `nextStepAt`. */ +export const AlertEscalationRunStatus = Schema.Literals([ + "active", + "acknowledged", + "resolved", + "exhausted", + "superseded", +]).annotate({ identifier: "@maple/AlertEscalationRunStatus", title: "Alert Escalation Run Status" }) +export type AlertEscalationRunStatus = Schema.Schema.Type + +export class EscalationChainNotFoundError extends HttpTaggedError()( + "@maple/http/errors/EscalationChainNotFoundError", + { message: Schema.String, chainId: EscalationChainId }, + { + status: 404, + code: "escalation_chain_not_found", + title: "Escalation chain not found", + message: "No such escalation chain.", + param: "id", + retry: "never", + recovery: "none", + exposure: "redacted", + }, +) {} + +export class EscalationChainInUseError extends HttpTaggedError()( + "@maple/http/errors/EscalationChainInUseError", + { + message: Schema.String, + chainId: EscalationChainId, + ruleIds: Schema.Array(AlertRuleId), + ruleNames: Schema.Array(Schema.String), + }, + { + status: 409, + code: "escalation_chain_in_use", + title: "Escalation chain is in use", + message: "The escalation chain is used by one or more alert rules.", + retry: "never", + recovery: "fix_request", + exposure: "redacted", + }, +) {} diff --git a/packages/domain/src/http/index.ts b/packages/domain/src/http/index.ts index 2ce5160a73..08a06c00f6 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 "./escalation-chains" export * from "./agent-feedback" export * from "./mcp-connections" export * from "./mcp-tools" diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index d066156105..611ecaacc6 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -198,6 +198,10 @@ export type AlertDeliveryEventId = Schema.Schema.Type +export const EscalationChainId = MapleUuidId("@maple/EscalationChainId", "Escalation Chain ID") +export type EscalationChainId = Schema.Schema.Type +export const AlertEscalationRunId = MapleUuidId("@maple/AlertEscalationRunId", "Alert Escalation Run ID") +export type AlertEscalationRunId = Schema.Schema.Type export const MobileDeviceId = MapleUuidId("@maple/MobileDeviceId", "Mobile Device ID") export type MobileDeviceId = Schema.Schema.Type From 0a0e10a4a17e5a4906afe8352773190950ee0a40 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 12:48:59 +0100 Subject: [PATCH 10/98] test(mcp): acknowledge by inc_ id and assert the rendered text --- .../tools/acknowledge-alert-incident.test.ts | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/apps/ai/src/mcp/tools/acknowledge-alert-incident.test.ts b/apps/ai/src/mcp/tools/acknowledge-alert-incident.test.ts index e992aef5bb..7d5bc15175 100644 --- a/apps/ai/src/mcp/tools/acknowledge-alert-incident.test.ts +++ b/apps/ai/src/mcp/tools/acknowledge-alert-incident.test.ts @@ -1,12 +1,14 @@ import { afterEach, assert, describe, it } from "@effect/vitest" import { ConfigProvider, Effect, Layer, Schema } from "effect" import { OrgId, UserId } from "@maple/domain/http" +import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" import { Env } from "@maple/backend/platform/Env" import { EdgeCacheServiceLive } from "@maple/backend/platform/CacheBackendLive" import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@maple/backend/platform/test-pglite" import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import { CurrentMcpTenant } from "../lib/query-warehouse" import { registerAcknowledgeAlertIncidentTool } from "./acknowledge-alert-incident" +import { renderToolDoc } from "../lib/tool-doc" import { CurrentAuditActor } from "@maple/backend/services/auth/audit-actor" import type { McpToolError, McpToolRegistrar } from "./types" import type { McpToolRequirements } from "./runtime-requirements" @@ -23,12 +25,17 @@ type Invoke = ( params: Readonly>, ) => Effect.Effect -const acknowledgeTool = (): Invoke => { +/** Calls the tool's handler; with `rendered`, returns the text the model reads instead. */ +const acknowledgeTool = (rendered = false): Invoke => { let invoke: Invoke = () => Effect.die("unregistered") const registrar: McpToolRegistrar = { define: (spec) => { invoke = (params) => - Schema.decodeEffect(spec.parameters)(params).pipe(Effect.orDie, Effect.flatMap(spec.handler)) + Schema.decodeEffect(spec.parameters)(params).pipe( + Effect.orDie, + Effect.flatMap(spec.handler), + Effect.map((output) => (rendered ? renderToolDoc(spec.render(output)) : output)), + ) }, } registerAcknowledgeAlertIncidentTool(registrar) @@ -87,7 +94,8 @@ describe("acknowledge_alert_incident", () => { const call = acknowledgeTool() return Effect.gen(function* () { yield* seed(testDb) - const acked = yield* call({ incident_id: OPEN }) + // The `inc_…` id the v2 API and the other tools print, not the internal UUID. + const acked = yield* call({ incident_id: encodePublicId(PublicIdPrefixes.alertIncident, OPEN) }) assert.deepInclude(acked, { id: OPEN, status: "open", @@ -105,6 +113,25 @@ describe("acknowledge_alert_incident", () => { }).pipe(Effect.provide(layerFor(testDb))) }) + it.effect("renders the change, a no-op notice, and the timeline as the next call", () => { + const testDb = createTestDb(trackedDbs) + const render = acknowledgeTool(true) + return Effect.gen(function* () { + yield* seed(testDb) + const acked = yield* render({ incident_id: OPEN }) + assert.include(acked, "## Incident Acknowledged") + assert.include(acked, "Checkout errors") + assert.include(acked, "someone via mcp") + assert.include(acked, "get_incident_timeline") + assert.include(acked, "00000000-0000-4000-8000-0000000000b0") + assert.notInclude(acked, "nothing changed") + assert.include(yield* render({ incident_id: OPEN }), "Already acknowledged; nothing changed.") + const unacked = yield* render({ incident_id: OPEN, acknowledged: false }) + assert.include(unacked, "## Incident Unacknowledged") + assert.notInclude(unacked, "someone via mcp") + }).pipe(Effect.provide(layerFor(testDb))) + }) + it.effect("names a resolved or unknown incident as invalid input on incident_id", () => { const testDb = createTestDb(trackedDbs) const call = acknowledgeTool() From eac581916132c7475fc5d03981eb01c4822069a8 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 12:52:13 +0100 Subject: [PATCH 11/98] test(alerts): escalation chains walk, repeat, stop on ack and resolve, and send each step once --- .../alerts/AlertsService.escalation.test.ts | 398 ++++++++++++++++++ .../services/alerts/alert-escalation-runs.ts | 4 +- .../src/services/alerts/alert-thresholds.ts | 11 +- 3 files changed, 408 insertions(+), 5 deletions(-) create mode 100644 packages/backend/src/services/alerts/AlertsService.escalation.test.ts diff --git a/packages/backend/src/services/alerts/AlertsService.escalation.test.ts b/packages/backend/src/services/alerts/AlertsService.escalation.test.ts new file mode 100644 index 0000000000..1c5b0b86a9 --- /dev/null +++ b/packages/backend/src/services/alerts/AlertsService.escalation.test.ts @@ -0,0 +1,398 @@ +import { + AlertDestinationInUseError, + AlertRuleUpsertRequest, + AlertValidationError, + EscalationChainCreateRequest, + EscalationChainId, + EscalationChainInUseError, + WebhookAlertDestinationConfig, + type AlertDestinationId, + type AlertThreshold, + type OrgId, + type UserId, +} from "@maple/domain/http" +import { afterEach, assert, describe, it } from "@effect/vitest" +import { Duration, Effect, Layer, Schema, Array as Arr, String as Str } from "effect" +import { TestClock } from "effect/testing" +import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@maple/backend/platform/test-pglite" +import { AlertsService, type AlertsServiceApi } from "./AlertsService" +import { EscalationChainsService } from "./EscalationChainsService" +import { + DEFAULT_CLOCK_EPOCH_MS, + adminRoles, + asOrgId, + asUserId, + getError, + makeLayer, + makeWarehouseStub, + tracesRow, +} from "./alerts-service-test-support" + +const decodeChainId = Schema.decodeUnknownSync(EscalationChainId) +const trackedDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(trackedDbs)) + +const errorRateRows = (errorRate: number): ReadonlyArray> => [ + tracesRow({ + count: 200, + avgDuration: 40, + p50Duration: 20, + p95Duration: 120, + p99Duration: 240, + errorRate, + satisfiedCount: 180, + toleratingCount: 10, + apdexScore: 0.925, + }), +] + +/** `trigger https://example.com/slack` per webhook request, in send order. */ +const recordingFetch = (requests: Array) => + (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input) + requests.push( + `${new Headers(init?.headers).get("x-maple-event-type")} ${Str.replace("https://example.com/", "")(url)}`, + ) + return new Response("ok", { status: 200 }) + }) as typeof fetch + +const layerFor = ( + testDb: TestDb, + state: { tracesAggregateRows: ReadonlyArray> }, + requests: Array, +) => + Layer.mergeAll( + makeLayer(testDb, makeWarehouseStub(state), { fetch: recordingFetch(requests) }), + EscalationChainsService.layer.pipe(Layer.provide(testDb.layer)), + ) + +const tick = (alerts: AlertsServiceApi, minutes = 1) => + Effect.gen(function* () { + yield* TestClock.adjust(Duration.minutes(minutes)) + yield* alerts.runSchedulerTick() + }) + +/** Ticks once a minute for `minutes` minutes. */ +const ticks = (alerts: AlertsServiceApi, minutes: number) => + Effect.forEach(Arr.range(1, minutes), () => tick(alerts), { discard: true }) + +const webhook = (alerts: AlertsServiceApi, orgId: OrgId, userId: UserId, name: string) => + alerts.createDestination( + orgId, + userId, + adminRoles, + new WebhookAlertDestinationConfig({ + type: "webhook", + name, + enabled: true, + url: `https://example.com/${name}`, + signingSecret: `${name}-secret`, + }), + ) + +const ruleRequest = (thresholds: Arr.NonEmptyReadonlyArray) => + new AlertRuleUpsertRequest({ + name: "Checkout error rate", + enabled: true, + serviceNames: ["checkout"], + signalType: "error_rate", + comparator: "gt", + thresholds, + windowMinutes: 5, + minimumSampleCount: 10, + consecutiveBreachesRequired: 2, + consecutiveHealthyRequired: 2, + renotifyIntervalMinutes: 60, + }) + +const tier = ( + severity: AlertThreshold["severity"], + value: number, + destinationIds: ReadonlyArray, + escalationChainId: EscalationChainId | null, +): AlertThreshold => ({ + severity, + value, + upper: null, + recoveryValue: null, + destinationIds, + escalationChainId, +}) + +/** "#team" on the tier; the chain notifies slack, waits 10 minutes, then pages, waits 15. */ +const setup = (orgName: string, options: { readonly repeatCount?: number } = {}) => + Effect.gen(function* () { + const alerts = yield* AlertsService + const chains = yield* EscalationChainsService + const orgId = asOrgId(orgName) + const userId = asUserId(`user_${orgName}`) + const team = yield* webhook(alerts, orgId, userId, "team") + const slack = yield* webhook(alerts, orgId, userId, "slack") + const pager = yield* webhook(alerts, orgId, userId, "pager") + const chain = yield* chains.create( + orgId, + userId, + adminRoles, + new EscalationChainCreateRequest({ + name: "API escalation", + steps: [ + { targets: [{ type: "destination", destinationId: slack.id }], waitMinutes: 10 }, + { targets: [{ type: "destination", destinationId: pager.id }], waitMinutes: 15 }, + ], + ...(options.repeatCount === undefined ? undefined : { repeatCount: options.repeatCount }), + }), + ) + const rule = yield* alerts.createRule( + orgId, + userId, + adminRoles, + ruleRequest([tier("critical", 5, [team.id], chain.id)]), + ) + return { alerts, chains, orgId, userId, team, slack, pager, chain, rule } + }) + +const runRows = (testDb: TestDb, orgId: OrgId) => + Effect.promise(() => + testDb.pglite.query<{ status: string; steps_sent: number; chain_name: string }>( + "select status, steps_sent, chain_name from alert_escalation_runs where org_id = $1 order by created_at", + [orgId], + ), + ).pipe(Effect.map((result) => result.rows)) + +const setAck = (testDb: TestDb, orgId: OrgId, acknowledged: boolean) => + Effect.promise(() => + executeSql( + testDb, + acknowledged + ? `update alert_incidents set acknowledged_at = now(), acknowledged_by_name = 'Ana', acknowledged_via = 'web' where org_id = $1` + : `update alert_incidents set acknowledged_at = null, acknowledged_by_name = null, acknowledged_via = null where org_id = $1`, + [orgId], + ), + ) + +describe("escalation chains", () => { + it.effect("pages the next step after the wait, once, and ends after the last step", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRateRows(10) } + const requests: Array = [] + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const { alerts, orgId } = yield* setup("org_esc_walk") + yield* alerts.runSchedulerTick() + yield* tick(alerts) + // Opened: the tier's destination and the chain's first step, in the same tick. + assert.sameMembers(requests, ["trigger team", "trigger slack"]) + + yield* ticks(alerts, 9) + assert.lengthOf(requests, 2) + yield* tick(alerts) + assert.deepStrictEqual(requests.slice(2), ["trigger pager"]) + + yield* ticks(alerts, 20) + assert.lengthOf(requests, 3) + assert.deepStrictEqual(yield* runRows(testDb, orgId), [ + { status: "exhausted", steps_sent: 2, chain_name: "API escalation" }, + ]) + }).pipe(Effect.provide(layerFor(testDb, state, requests))) + }) + + it.effect("repeats the whole chain repeatCount more times", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRateRows(10) } + const requests: Array = [] + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const { alerts, orgId } = yield* setup("org_esc_repeat", { repeatCount: 1 }) + yield* alerts.runSchedulerTick() + yield* tick(alerts) + // slack at 0, pager at 10, slack again at 25, pager at 35. + yield* ticks(alerts, 40) + assert.deepStrictEqual( + Arr.filter(requests, (request) => request !== "trigger team"), + ["trigger slack", "trigger pager", "trigger slack", "trigger pager"], + ) + assert.strictEqual((yield* runRows(testDb, orgId))[0]?.status, "exhausted") + }).pipe(Effect.provide(layerFor(testDb, state, requests))) + }) + + it.effect("stops at an acknowledgement and picks up where it was after an unacknowledge", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRateRows(10) } + const requests: Array = [] + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const { alerts, orgId } = yield* setup("org_esc_ack") + yield* alerts.runSchedulerTick() + yield* tick(alerts) + yield* setAck(testDb, orgId, true) + yield* ticks(alerts, 20) + // Nobody paged while Ana is on it. + assert.sameMembers(requests, ["trigger team", "trigger slack"]) + assert.strictEqual((yield* runRows(testDb, orgId))[0]?.status, "acknowledged") + + yield* setAck(testDb, orgId, false) + yield* tick(alerts) + assert.deepStrictEqual(requests.slice(2), ["trigger pager"]) + }).pipe(Effect.provide(layerFor(testDb, state, requests))) + }) + + it.effect("ends without paging once the incident resolves mid-wait", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRateRows(10) } + const requests: Array = [] + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const { alerts, orgId } = yield* setup("org_esc_resolve") + yield* alerts.runSchedulerTick() + yield* tick(alerts) + state.tracesAggregateRows = errorRateRows(1) + yield* ticks(alerts, 3) + yield* ticks(alerts, 20) + assert.notInclude(requests, "trigger pager") + assert.strictEqual((yield* runRows(testDb, orgId))[0]?.status, "resolved") + }).pipe(Effect.provide(layerFor(testDb, state, requests))) + }) + + it.effect("sends a due step once when two scheduler ticks overlap", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRateRows(10) } + const requests: Array = [] + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const { alerts, orgId } = yield* setup("org_esc_race") + yield* alerts.runSchedulerTick() + yield* tick(alerts) + yield* ticks(alerts, 9) + yield* TestClock.adjust(Duration.minutes(1)) + yield* Effect.all([alerts.runSchedulerTick(), alerts.runSchedulerTick()], { concurrency: 2 }) + assert.deepStrictEqual( + Arr.filter(requests, (request) => request === "trigger pager"), + ["trigger pager"], + ) + const rows = yield* Effect.promise(() => + testDb.pglite.query<{ n: number }>( + `select count(*)::int as n from alert_delivery_events d join alert_destinations x on x.id = d.destination_id + where d.org_id = $1 and x.name = 'pager'`, + [orgId], + ), + ) + assert.strictEqual(rows.rows[0]?.n, 1) + }).pipe(Effect.provide(layerFor(testDb, state, requests))) + }) + + it.effect("notifies a destination in both the tier and the first step once", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRateRows(10) } + const requests: Array = [] + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const alerts = yield* AlertsService + const chains = yield* EscalationChainsService + const orgId = asOrgId("org_esc_dedupe") + const userId = asUserId("user_esc_dedupe") + const team = yield* webhook(alerts, orgId, userId, "team") + const chain = yield* chains.create( + orgId, + userId, + adminRoles, + new EscalationChainCreateRequest({ + name: "Team first", + steps: [{ targets: [{ type: "destination", destinationId: team.id }], waitMinutes: 5 }], + }), + ) + yield* alerts.createRule( + orgId, + userId, + adminRoles, + ruleRequest([tier("critical", 5, [team.id], chain.id)]), + ) + yield* alerts.runSchedulerTick() + yield* tick(alerts) + assert.deepStrictEqual(requests, ["trigger team"]) + }).pipe(Effect.provide(layerFor(testDb, state, requests))) + }) + + it.effect("restarts on the critical tier's own chain when a warning incident escalates", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRateRows(10) } + const requests: Array = [] + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const alerts = yield* AlertsService + const chains = yield* EscalationChainsService + const orgId = asOrgId("org_esc_supersede") + const userId = asUserId("user_esc_supersede") + const slack = yield* webhook(alerts, orgId, userId, "slack") + const pager = yield* webhook(alerts, orgId, userId, "pager") + const create = (name: string, destinationId: AlertDestinationId, repeatCount: number) => + chains.create( + orgId, + userId, + adminRoles, + new EscalationChainCreateRequest({ + name, + steps: [{ targets: [{ type: "destination", destinationId }], waitMinutes: 30 }], + repeatCount, + }), + ) + // The warning chain is still waiting to repeat when the incident escalates. + const warnChain = yield* create("Warn chain", slack.id, 1) + const pageChain = yield* create("Page chain", pager.id, 0) + yield* alerts.createRule( + orgId, + userId, + adminRoles, + ruleRequest([tier("critical", 20, [], pageChain.id), tier("warning", 5, [], warnChain.id)]), + ) + yield* alerts.runSchedulerTick() + yield* tick(alerts) + assert.deepStrictEqual(requests, ["trigger slack"]) + state.tracesAggregateRows = errorRateRows(30) + yield* ticks(alerts, 2) + assert.deepStrictEqual(requests, ["trigger slack", "trigger pager"]) + assert.deepStrictEqual( + Arr.map(yield* runRows(testDb, orgId), (row) => [row.chain_name, row.status]), + [ + ["Warn chain", "superseded"], + ["Page chain", "exhausted"], + ], + ) + }).pipe(Effect.provide(layerFor(testDb, state, requests))) + }) + + it.effect("refuses a rule naming an unknown chain, and deleting a chain or destination in use", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRateRows(10) } + const requests: Array = [] + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const { alerts, chains, orgId, userId, team, slack, chain, rule } = yield* setup("org_esc_guards") + const unknown = yield* Effect.exit( + alerts.createRule( + orgId, + userId, + adminRoles, + ruleRequest([ + tier("critical", 5, [team.id], decodeChainId("00000000-0000-4000-8000-0000000000ff")), + ]), + ), + ) + assert.instanceOf(getError(unknown), AlertValidationError) + + const inUse = getError(yield* Effect.exit(chains.delete(orgId, adminRoles, chain.id))) + assert.instanceOf(inUse, EscalationChainInUseError) + assert.deepStrictEqual((yield* chains.get(orgId, chain.id)).ruleIds, [rule.id]) + + const destinationInUse = getError( + yield* Effect.exit(alerts.deleteDestination(orgId, adminRoles, slack.id)), + ) + if (!Schema.is(AlertDestinationInUseError)(destinationInUse)) + return assert.fail("expected in use") + assert.include(destinationInUse.message, "API escalation") + + yield* alerts.deleteRule(orgId, adminRoles, rule.id) + yield* chains.delete(orgId, adminRoles, chain.id) + assert.lengthOf((yield* chains.list(orgId)).chains, 0) + }).pipe(Effect.provide(layerFor(testDb, state, requests))) + }) +}) diff --git a/packages/backend/src/services/alerts/alert-escalation-runs.ts b/packages/backend/src/services/alerts/alert-escalation-runs.ts index 5254c955a6..450e941b9a 100644 --- a/packages/backend/src/services/alerts/alert-escalation-runs.ts +++ b/packages/backend/src/services/alerts/alert-escalation-runs.ts @@ -23,7 +23,7 @@ import { EscalationChains, } from "@maple/db/tables" import type { MapleDb } from "@maple/db/client" -import { Array as Arr, Effect } from "effect" +import { Array as Arr, DateTime, Effect } from "effect" import { queuedDeliveryEventRow } from "./alert-delivery-rows" const LIVE_STATUSES = ["active", "acknowledged"] as const @@ -241,7 +241,7 @@ export const applyEscalationPlan = (db: MapleDb, input: ApplyEscalationPlanInput if (!moved) return "lost" satisfies EscalationApplyOutcome const payload = { ...row.payloadJson, - sentAt: new Date(nowMs).toISOString(), + sentAt: DateTime.formatIso(DateTime.makeUnsafe(nowMs)), escalation: { chainId: row.chainId, chainName: row.chainName, diff --git a/packages/backend/src/services/alerts/alert-thresholds.ts b/packages/backend/src/services/alerts/alert-thresholds.ts index e635d5c788..b2a76ec904 100644 --- a/packages/backend/src/services/alerts/alert-thresholds.ts +++ b/packages/backend/src/services/alerts/alert-thresholds.ts @@ -151,11 +151,16 @@ export const validateThresholds = ( ) } } - if (options.requireDestinations && tier.destinationIds.length === 0) { + // An escalation chain routes the tier on its own. + if ( + options.requireDestinations && + tier.destinationIds.length === 0 && + tier.escalationChainId == null + ) { details.push( thresholds.length === 1 - ? "at least one destination must be selected" - : `the ${tier.severity} threshold needs at least one destination`, + ? "at least one destination or an escalation chain must be selected" + : `the ${tier.severity} threshold needs at least one destination or an escalation chain`, ) } }) From 17b8598ee90c83d7d0bd05fea60bd88a8f29f4f0 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 13:07:33 +0100 Subject: [PATCH 12/98] feat(alerts): escalation chains in the v2 API and MCP, and rule tiers that name one --- apps/ai/src/mcp/dispatcher.ts | 2 + apps/ai/src/mcp/lib/alert-rules.ts | 25 ++ apps/ai/src/mcp/lib/escalation-chains.ts | 174 ++++++++++++ apps/ai/src/mcp/lib/params.ts | 6 + apps/ai/src/mcp/resources/instructions.ts | 1 + apps/ai/src/mcp/tool-descriptors.generated.ts | 14 +- .../mcp/tools/__tests__/alert-tools.test.ts | 117 ++++++++ .../__tests__/escalation-chain-tools.test.ts | 261 +++++++++++++++++ apps/ai/src/mcp/tools/create-alert-rule.ts | 28 +- .../src/mcp/tools/create-escalation-chain.ts | 76 +++++ .../src/mcp/tools/delete-escalation-chain.ts | 52 ++++ apps/ai/src/mcp/tools/get-alert-rule.ts | 28 +- .../src/mcp/tools/list-escalation-chains.ts | 64 +++++ apps/ai/src/mcp/tools/mutating.ts | 3 + apps/ai/src/mcp/tools/registry.ts | 8 + apps/ai/src/mcp/tools/runtime-requirements.ts | 2 + apps/ai/src/mcp/tools/update-alert-rule.ts | 30 +- .../src/mcp/tools/update-escalation-chain.ts | 80 ++++++ .../test/mcp/unimplemented-tool-services.ts | 2 + apps/api/src/routes/v2/alerts.http.test.ts | 174 +++++++++++- .../src/routes/v2/escalation-chains.http.ts | 141 ++++++++++ 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 + .../MapleAPI/Sources/MapleAPI/openapi.json | 19 ++ .../landing/src/content/docs/reference/mcp.md | 4 + apps/web/e2e/tests/alert-rules.e2e.ts | 3 + apps/web/src/page/alerts/editor/form-steps.ts | 7 +- apps/web/src/page/alerts/editor/form.ts | 10 +- .../conversation/kit/tool-metadata.ts | 8 + packages/domain/src/audit-actions.ts | 8 + .../anticipated-error-identifiers.ts | 2 + packages/domain/src/http/v2/alert-rules.ts | 11 +- packages/domain/src/http/v2/api.ts | 2 + .../domain/src/http/v2/escalation-chains.ts | 264 ++++++++++++++++++ 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 + packages/domain/src/http/v2/resource-ids.ts | 2 + .../domain/src/http/v2/v2-contract.test.ts | 55 ++++ packages/domain/src/mcp-outputs/alerts.ts | 10 + packages/domain/src/mcp-outputs/catalog.ts | 9 + .../src/mcp-outputs/escalation-chains.ts | 39 +++ packages/domain/src/mcp-outputs/index.ts | 1 + .../web-data/src/alerts/form-utils.test.ts | 27 +- packages/web-data/src/alerts/form-utils.ts | 9 + 46 files changed, 1775 insertions(+), 18 deletions(-) create mode 100644 apps/ai/src/mcp/lib/escalation-chains.ts create mode 100644 apps/ai/src/mcp/tools/__tests__/escalation-chain-tools.test.ts create mode 100644 apps/ai/src/mcp/tools/create-escalation-chain.ts create mode 100644 apps/ai/src/mcp/tools/delete-escalation-chain.ts create mode 100644 apps/ai/src/mcp/tools/list-escalation-chains.ts create mode 100644 apps/ai/src/mcp/tools/update-escalation-chain.ts create mode 100644 apps/api/src/routes/v2/escalation-chains.http.ts create mode 100644 packages/domain/src/http/v2/escalation-chains.ts create mode 100644 packages/domain/src/mcp-outputs/escalation-chains.ts diff --git a/apps/ai/src/mcp/dispatcher.ts b/apps/ai/src/mcp/dispatcher.ts index 9c07575f90..08299cc610 100644 --- a/apps/ai/src/mcp/dispatcher.ts +++ b/apps/ai/src/mcp/dispatcher.ts @@ -12,6 +12,7 @@ import { AlertsService } from "@maple/backend/services/alerts/AlertsService" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" +import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" import { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService" import { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService" @@ -272,6 +273,7 @@ const McpRuntimeServicesLive = Layer.mergeAll( AlertReadModelsService.layer, AlertIncidentAckService.layer, AlertRulesService.layer, + EscalationChainsService.layer, AlertsService.layer, AuditLogService.layer, DashboardPersistenceService.layer, diff --git a/apps/ai/src/mcp/lib/alert-rules.ts b/apps/ai/src/mcp/lib/alert-rules.ts index 4943da9d11..ae0ad8ac8a 100644 --- a/apps/ai/src/mcp/lib/alert-rules.ts +++ b/apps/ai/src/mcp/lib/alert-rules.ts @@ -81,10 +81,34 @@ export const toAlertRuleRow = (rule: AlertRuleDocument): typeof AlertRuleRow.Typ evaluationIntervalMinutes: rule.evaluationIntervalMinutes, slowedIntervalMinutes: rule.slowedIntervalMinutes, destinationIds: [...rule.destinationIds], + ...escalationChainsOf(rule), createdAt: rule.createdAt, updatedAt: rule.updatedAt, }) +/** The chains a rule's tiers start, keyed by severity; absent when no tier names one. */ +const escalationChainsOf = (rule: Pick) => { + const chains = Arr.flatMap(rule.thresholds, (tier) => + tier.escalationChainId == null ? [] : [{ severity: tier.severity, chainId: tier.escalationChainId }], + ) + return chains.length === 0 ? undefined : { escalationChains: chains } +} + +/** e.g. `critical: Checkout on-call (3f2a…)`, or the bare id when the name was not looked up. */ +export const formatEscalationChains = ( + chains: (typeof AlertRuleRow.Type)["escalationChains"], +): string | undefined => + chains === undefined + ? undefined + : Arr.join( + Arr.map(chains, (c) => + c.name == null + ? `${c.severity}: ${c.chainId}` + : `${c.severity}: ${c.name} (${c.chainId})`, + ), + "; ", + ) + interface RuleCadence { readonly windowMinutes: number readonly evaluationIntervalMinutes: number | null @@ -266,6 +290,7 @@ export const renderRuleWrite = ( ["Window", `${rule.windowMinutes}m, checked ${formatCheckInterval(rule)}`], ["Enabled", rule.enabled ? "Yes" : "No"], ["Destinations", rule.destinationIds.length], + ["Escalation", formatEscalationChains(rule.escalationChains)], [ "Environments", rule.environments.length > 0 ? Arr.join(rule.environments, ", ") : undefined, diff --git a/apps/ai/src/mcp/lib/escalation-chains.ts b/apps/ai/src/mcp/lib/escalation-chains.ts new file mode 100644 index 0000000000..4bbf440b24 --- /dev/null +++ b/apps/ai/src/mcp/lib/escalation-chains.ts @@ -0,0 +1,174 @@ +/** Pieces the escalation chain MCP tools share: rows, step text, step input and error mapping. */ +import { Array as Arr, Effect, Option, Schema } from "effect" +import type { + AlertDestinationDocument, + AlertForbiddenError, + OrgId, + AlertPersistenceError, + AlertValidationError, + EscalationChainDocument, + EscalationChainInUseError, + EscalationChainNotFoundError, +} from "@maple/domain/http" +import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" +import type { EscalationChainRow } from "@maple/domain/mcp-outputs" +import { AlertsService } from "@maple/backend/services/alerts/AlertsService" +import { McpInvalidInputError } from "../tools/types" +import { toMcpHttpError } from "./map-http-error" +import * as P from "./params" +import { doc } from "./tool-doc" + +type ChainRow = typeof EscalationChainRow.Type +type DestinationRef = Pick + +/** The steps a tool takes: each the destinations it notifies and the minutes it waits. */ +export const StepsInput = Schema.Array( + Schema.Struct({ + destination_ids: Schema.Array(Schema.String).annotate({ + description: "Destinations this step notifies, from list_alert_destinations (UUID or dest_…)", + }), + wait_minutes: Schema.Number.annotate({ + description: "Minutes to wait for an acknowledgement before the next step, 1 to 1440", + }), + }), +) + +export const stepsParameterDescription = + 'Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{"destination_ids":["dest_…"],"wait_minutes":10},{"destination_ids":["dest_…"],"wait_minutes":15}]. At most 10 steps of 10 destinations' + +/** The steps as the service schema takes them, destination ids decoded to internal ids. */ +export const toStepsRequest = (steps: typeof StepsInput.Type) => + Effect.forEach(steps, (step) => + Effect.map( + Effect.forEach(step.destination_ids, P.decodeResourceId("alertDestination", "steps")), + (ids) => ({ + targets: Arr.map(ids, (destinationId) => ({ type: "destination" as const, destinationId })), + waitMinutes: step.wait_minutes, + }), + ), + ) + +/** Destination names for the rendered steps; a failed read only drops the names. */ +export const destinationDirectory = (orgId: OrgId) => + Effect.gen(function* () { + const alerts = yield* AlertsService + return yield* alerts.listDestinations(orgId).pipe( + Effect.map((response): ReadonlyArray => response.destinations), + Effect.orElseSucceed((): ReadonlyArray => []), + ) + }) + +export const toEscalationChainRow = ( + chain: EscalationChainDocument, + destinations: ReadonlyArray, +): ChainRow => ({ + id: chain.id, + publicId: encodePublicId(PublicIdPrefixes.escalationChain, chain.id), + name: chain.name, + description: chain.description, + steps: Arr.map(chain.steps, (step) => ({ + targets: Arr.map(step.targets, (target) => { + const known = Option.getOrUndefined( + Arr.findFirst(destinations, (d) => d.id === target.destinationId), + ) + return { + destinationId: target.destinationId, + name: known?.name ?? null, + type: known?.type ?? null, + } + }), + waitMinutes: step.waitMinutes, + })), + repeatCount: chain.repeatCount, + ruleIds: [...chain.ruleIds], + createdAt: chain.createdAt, + updatedAt: chain.updatedAt, +}) + +const formatWait = (minutes: number): string => + minutes < 60 + ? `${minutes}m` + : minutes % 60 === 0 + ? `${minutes / 60}h` + : `${Math.floor(minutes / 60)}h ${minutes % 60}m` + +const targetLabel = (target: ChainRow["steps"][number]["targets"][number]): string => + target.name === null + ? `destination ${target.destinationId}` + : target.type === null + ? target.name + : `${target.name} (${target.type})` + +/** One line per step, e.g. `1. notify On-call (pagerduty), then wait 10m for an ack`. */ +export const formatSteps = (chain: ChainRow): string => + Arr.join( + Arr.map( + chain.steps, + (step, index) => + `${index + 1}. notify ${Arr.join(Arr.map(step.targets, targetLabel), ", ")}, then wait ${formatWait(step.waitMinutes)} for an ack`, + ), + "\n", + ) + +export const formatRepeats = (repeatCount: number): string => + repeatCount === 0 ? "runs once" : `repeats ${repeatCount} more time${repeatCount === 1 ? "" : "s"}` + +export const chainBlocks = (chain: ChainRow) => [ + doc.fields([ + ["ID", chain.id], + ["REST id", chain.publicId], + ["Name", chain.name], + ["Description", chain.description ?? undefined], + ["Passes", formatRepeats(chain.repeatCount)], + ["Used by rules", chain.ruleIds.length > 0 ? Arr.join(chain.ruleIds, ", ") : "none"], + ]), + doc.text(formatSteps(chain)), +] + +export const chainNotFound = (chainId: string) => + new McpInvalidInputError({ + message: `Escalation chain not found: ${chainId}. Use list_escalation_chains to find chain IDs.`, + parameter: "chain_id", + }) + +const failWith = (pipeName: string) => (error: AlertForbiddenError | AlertPersistenceError) => + Effect.fail(toMcpHttpError(pipeName)(error)) + +const invalidChain = (error: AlertValidationError) => + Effect.fail( + new McpInvalidInputError({ + message: `Invalid escalation chain: ${error.message}`, + parameter: "steps", + }), + ) + +export const createChainErrors = { + "@maple/http/errors/AlertValidationError": invalidChain, + "@maple/http/errors/AlertForbiddenError": failWith("create_escalation_chain"), + "@maple/http/errors/AlertPersistenceError": failWith("create_escalation_chain"), +} + +export const updateChainErrors = { + "@maple/http/errors/AlertValidationError": invalidChain, + "@maple/http/errors/EscalationChainNotFoundError": (error: EscalationChainNotFoundError) => + Effect.fail(chainNotFound(error.chainId)), + "@maple/http/errors/AlertForbiddenError": failWith("update_escalation_chain"), + "@maple/http/errors/AlertPersistenceError": failWith("update_escalation_chain"), +} + +export const deleteChainErrors = { + "@maple/http/errors/EscalationChainNotFoundError": (error: EscalationChainNotFoundError) => + Effect.fail(chainNotFound(error.chainId)), + "@maple/http/errors/EscalationChainInUseError": (error: EscalationChainInUseError) => + Effect.fail( + new McpInvalidInputError({ + message: `Escalation chain is still used by ${Arr.join( + Arr.map(error.ruleIds, (id, index) => `"${error.ruleNames[index] ?? id}" (${id})`), + ", ", + )}. Detach it first with update_alert_rule escalation_chain_id="none".`, + parameter: "chain_id", + }), + ), + "@maple/http/errors/AlertForbiddenError": failWith("delete_escalation_chain"), + "@maple/http/errors/AlertPersistenceError": failWith("delete_escalation_chain"), +} diff --git a/apps/ai/src/mcp/lib/params.ts b/apps/ai/src/mcp/lib/params.ts index 2ee14fe336..6d5b8cefe6 100644 --- a/apps/ai/src/mcp/lib/params.ts +++ b/apps/ai/src/mcp/lib/params.ts @@ -24,6 +24,7 @@ import { AlertRuleId, DashboardId, ErrorIssueId, + EscalationChainId, } from "@maple/domain/primitives" import { WarehouseTimeInput, type WarehouseDateTime } from "@maple/query-engine" import { McpInvalidInputError } from "../tools/types" @@ -157,6 +158,11 @@ const RESOURCE_IDS = { schema: AlertDestinationId, noun: "alert destination", }, + escalationChain: { + prefix: PublicIdPrefixes.escalationChain, + schema: EscalationChainId, + noun: "escalation chain", + }, 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..8975b6cff8 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 +- Paging step by step until someone acknowledges: list_escalation_chains / create_escalation_chain (steps of destinations + wait_minutes) / update_escalation_chain / delete_escalation_chain; attach to a rule with create_alert_rule / update_alert_rule escalation_chain_id. The rule's own destinations still fire at once. - 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 f99f020768..4366c1489b 100644 --- a/apps/ai/src/mcp/tool-descriptors.generated.ts +++ b/apps/ai/src/mcp/tool-descriptors.generated.ts @@ -27,17 +27,21 @@ export const MCP_TOOL_DESCRIPTORS: ReadonlyArray = [ {"name":"query_data","title":"Query Data","description":"Aggregate traces, logs, metrics or product events into a timeseries or a top-N breakdown. Start here for trends, comparisons and top-N; for errors prefer find_errors and error_detail; for a shape this tool cannot express, run_sql. Applied defaults are listed in the result. A traces breakdown by span_name or attribute reads raw spans: set `service` and keep the window short (a window too large is narrowed, with a note). For endpoint volume over days use route_usage.","inputSchema":{"type":"object","properties":{"source":{"type":"string","enum":["traces","logs","metrics","product_events"],"description":"'traces' for request/span analysis (latency, errors, throughput); 'logs' for log volume; 'metrics' for a custom metric (needs metric_name and metric_type from list_metrics); 'product_events' for track() events, page views and server events (names from list_product_events)."},"kind":{"type":"string","enum":["timeseries","breakdown"],"description":"'timeseries' for how a value changed over time; 'breakdown' for top-N or distribution. Pick the right one first rather than calling twice."},"metric":{"type":"string","description":"Traces: count, avg_duration, p50_duration, p95_duration, p99_duration, error_rate (0-1 ratio), apdex (needs apdex_threshold_ms). Logs: count. Product events: count, sessions, persons, users, visitors. Metrics: avg, sum, min, max, count, rate, increase. For counters (sum metrics, Prometheus *_total gauges) use increase (total over the window) or rate (per second); both are per-series and reset-aware. sum adds raw samples. Default: count, or avg for metrics."},"group_by":{"type":"string","description":"Traces: service, span_name, status_code, http_method, attribute, resource_attribute. Logs: service, severity. Metrics: service, attribute, resource_attribute. Product events: event_name, kind, source, host, page_path, service, group, attribute. 'attribute' and 'resource_attribute' need attribute_key (resource_attribute reads resource keys such as k8s.pod.name or deployment.environment). Timeseries also take 'none' (the default); breakdown defaults to service."},"start_time":{"type":"string","description":"Window start, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601). Default: 6 hours before end_time."},"end_time":{"type":"string","description":"Window end, UTC, same format. Default: now."},"service":{"type":"string","description":"Only this service (exact `service.name`)"},"span_name":{"type":"string","description":"Filter by span name (traces only). Exact match first; when nothing matches exactly it is matched as a case-insensitive substring"},"root_spans_only":{"type":"boolean","description":"Only include root spans (traces only)"},"environments":{"type":"array","items":{"type":"string"},"description":"Only these deployment environments (traces, logs, metrics; explore_attributes source=services lists them)"},"commit_shas":{"type":"array","items":{"type":"string"},"description":"Commit SHAs to filter (traces only)"},"apdex_threshold_ms":{"type":"number","description":"Apdex threshold in ms (traces only; needed for metric=apdex)"},"severity":{"type":"string","enum":["TRACE","DEBUG","INFO","WARN","ERROR","FATAL"],"description":"Only this log severity (logs only)"},"event_name":{"type":"array","items":{"type":"string"},"description":"Only these event names (product_events only)"},"event_kind":{"type":"array","items":{"type":"string","enum":["navigation","custom","screen"]},"description":"Only these event kinds (product_events only)"},"host":{"type":"string","description":"Only events fired on this site host (product_events only)"},"page_path":{"type":"string","description":"Only events fired on this page path (product_events only)"},"metric_name":{"type":"string","description":"The metric, from list_metrics (source=metrics only)"},"metric_type":{"type":"string","enum":["sum","gauge","histogram","exponential_histogram"],"description":"Type of `metric_name`, as list_metrics reports it"},"attribute_key":{"type":"string","description":"Attribute to filter on, or to group by with group_by=attribute. Span (or log/metric label) and resource attributes both work; the scope is resolved automatically"},"attribute_value":{"type":"string","description":"Exact value for attribute_key. Also applies when grouping by the same key"},"attribute_scope":{"type":"string","enum":["span","resource"],"description":"Where attribute_key lives: 'span' (span, log or metric-datapoint attributes) or 'resource' (deployment.environment, k8s.pod.name...). Resolved from the known keys when omitted"},"bucket_seconds":{"type":"number","description":"Bucket width in seconds (timeseries only; auto-computed when omitted)"},"limit":{"type":"number","description":"Max rows of a breakdown (default 10, max 100)"}},"required":["source","kind"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"timeRange":{"type":"object","properties":{"start":{"type":"string"},"end":{"type":"string"}},"required":["start","end"],"additionalProperties":true},"kind":{"type":"string"},"metric":{"type":"string"},"groupBy":{"type":"string"},"decisions":{"type":"array","items":{"type":"string"}},"warnings":{"type":"array","items":{"type":"string"}},"queryContext":{"type":"object","properties":{"source":{"type":"string","enum":["traces","logs","metrics","product_events"]},"serviceName":{"type":"string"},"eventName":{"type":"string"},"eventKind":{"type":"string"},"host":{"type":"string"},"pagePath":{"type":"string"},"spanName":{"type":"string"},"rootSpansOnly":{"type":"boolean"},"environments":{"type":"array","items":{"type":"string"}},"commitShas":{"type":"array","items":{"type":"string"}},"severity":{"type":"string"},"metricName":{"type":"string"},"metricType":{"type":"string"},"attributeFilters":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"value":{"type":"string"},"mode":{"type":"string"}},"required":["key","mode"],"additionalProperties":true}},"apdexThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"bucketSeconds":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["source"],"additionalProperties":true},"unit":{"type":"string","enum":["duration_ms","duration_us","duration_s","duration_ns","percent","number","bytes","requests_per_sec"]},"result":{"anyOf":[{"type":"object","properties":{"kind":{"type":"string","enum":["timeseries"]},"data":{"type":"array","items":{"type":"object","properties":{"bucket":{"type":"string"},"series":{"type":"object","additionalProperties":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}}},"required":["bucket","series"],"additionalProperties":true}}},"required":["kind","data"],"additionalProperties":true},{"type":"object","properties":{"kind":{"type":"string","enum":["breakdown"]},"data":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"value":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["name","value"],"additionalProperties":true}}},"required":["kind","data"],"additionalProperties":true}]},"emptyHints":{"type":"array","items":{"type":"string"}}},"required":["timeRange","kind","metric","queryContext","unit","result"],"additionalProperties":true},"annotations":{"title":"Query Data","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"run_sql","title":"Run SQL","description":"Run one read-only ClickHouse SELECT against the org's warehouse and return its rows: at most 1000 are fetched and the first 100 rendered, with the count reported. Use it for what query_data cannot express, to spot-check data, or to test SQL for a raw_sql widget. For trends and top-N prefer query_data. The SQL must AND in `$__orgFilter`; macros are listed on `sql`. Spans are in `traces` (no otel_spans/spans table), log lines in `logs`; also metrics_sum/metrics_gauge/metrics_histogram, error_events, session_replays, product_events and *_hourly/*_minutely rollups. Select 64-bit IDs and hashes as toString(col). Attribute columns are Map(String, String): SpanAttributes['http.route'], never JSON* functions. In traces the name is SpanName, StatusCode is 'Ok'/'Error' and Duration is nanoseconds. Compare timestamps to 'YYYY-MM-DD HH:MM:SS', not ISO T/Z strings; bucket with toStartOfInterval. Call describe_warehouse_tables for a table's columns, units and enum casing before guessing names.","inputSchema":{"type":"object","properties":{"sql":{"type":"string","description":"The SELECT. It MUST contain `$__orgFilter` (expands to `(OrgId = '')`) as a top-level AND condition; a query without it, or with it OR-ed against another condition, is rejected. In joins use `$__orgFilter(alias)` (`(alias.OrgId = '')`) once per joined table. Other macros: `$__timeFilter(Column)` (`Column >= AND Column <= `), `$__startTime`, `$__endTime`, `$__interval_s` (bucket width for toStartOfInterval). Only one statement; DDL/DML, SETTINGS and system tables are rejected."},"start_time":{"type":"string","description":"Window start, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601). Default: 6 hours before end_time."},"end_time":{"type":"string","description":"Window end, UTC, same format. Default: now."},"granularity_seconds":{"type":"number","description":"Value of `$__interval_s`. Auto-computed from the window when omitted."},"max_cell_chars":{"type":"number","description":"Clip each rendered cell to this many characters (default 120, max 2000). Raise it to read long values such as log bodies or stack traces."},"show_sql":{"type":"boolean","description":"Also return the macro-expanded SQL that ran (default false)."},"reason":{"type":"string","description":"Why you are running this query, in a sentence. Optional; does not change the query."}},"required":["sql"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"rowCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"columns":{"type":"array","items":{"type":"string"}},"rows":{"type":"array","items":{"type":"object"}},"truncated":{"type":"boolean"},"timeRange":{"type":"object","properties":{"start":{"type":"string"},"end":{"type":"string"}},"required":["start","end"],"additionalProperties":true},"maxCellChars":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowApplied":{"type":"boolean"},"expandedSql":{"type":"string"},"atRowCap":{"type":"boolean"},"rewrites":{"type":"array","items":{"type":"object","properties":{"from":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"additionalProperties":true}}},"required":["rowCount","columns","rows","truncated","timeRange"],"additionalProperties":true},"annotations":{"title":"Run SQL","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"service_map","title":"Service Map","description":"Show service-to-service dependencies with call counts, error rates, and latency per edge. Use to understand system architecture and identify problematic inter-service calls.","inputSchema":{"type":"object","properties":{"start_time":{"type":"string","description":"Window start, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601). Default: 6 hours before end_time."},"end_time":{"type":"string","description":"Window end, UTC, same format. Default: now."},"service":{"type":"string","description":"Only edges involving this service, as source or target (exact `service.name`)"},"environment":{"type":"string","description":"Only this deployment environment (e.g. production, staging)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"timeRange":{"type":"object","properties":{"start":{"type":"string"},"end":{"type":"string"}},"required":["start","end"],"additionalProperties":true},"edges":{"type":"array","items":{"type":"object","properties":{"sourceService":{"type":"string"},"targetService":{"type":"string"},"callCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"errorCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"avgDurationMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"maxDurationMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["sourceService","targetService","callCount","errorCount","avgDurationMs","maxDurationMs"],"additionalProperties":true}},"serviceCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"service":{"type":"string"},"environment":{"type":"string"}},"required":["timeRange","edges","serviceCount"],"additionalProperties":true},"annotations":{"title":"Service Map","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, - {"name":"list_alert_rules","title":"List Alert Rules","description":"List configured alert rules with severity, signal type and condition. Use list_alert_incidents for what has fired.","inputSchema":{"type":"object","properties":{"services":{"type":"array","items":{"type":"string"},"description":"Only rules covering any of these services: scoped to them, scoped to every service, or raw_query rules whose SQL or name mentions them"},"signal_type":{"type":"string","enum":["error_rate","p95_latency","p99_latency","apdex","throughput","builder_query","raw_query"],"description":"Only rules on this signal type"},"severity":{"type":"string","enum":["warning","critical"],"description":"Only rules with this severity"},"enabled_only":{"type":"boolean","description":"Only enabled rules"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"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}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"services":{"type":"array","items":{"type":"string"}},"signalType":{"type":"string"},"severity":{"type":"string"},"enabledOnly":{"type":"boolean"}},"required":["rules","total"],"additionalProperties":true},"annotations":{"title":"List Alert Rules","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"list_alert_rules","title":"List Alert Rules","description":"List configured alert rules with severity, signal type and condition. Use list_alert_incidents for what has fired.","inputSchema":{"type":"object","properties":{"services":{"type":"array","items":{"type":"string"},"description":"Only rules covering any of these services: scoped to them, scoped to every service, or raw_query rules whose SQL or name mentions them"},"signal_type":{"type":"string","enum":["error_rate","p95_latency","p99_latency","apdex","throughput","builder_query","raw_query"],"description":"Only rules on this signal type"},"severity":{"type":"string","enum":["warning","critical"],"description":"Only rules with this severity"},"enabled_only":{"type":"boolean","description":"Only enabled rules"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","enabled","severity","serviceNames","environments","signalType","comparator","threshold","windowMinutes","evaluationIntervalMinutes","slowedIntervalMinutes","destinationIds","createdAt","updatedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"services":{"type":"array","items":{"type":"string"}},"signalType":{"type":"string"},"severity":{"type":"string"},"enabledOnly":{"type":"boolean"}},"required":["rules","total"],"additionalProperties":true},"annotations":{"title":"List Alert Rules","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"list_alert_destinations","title":"List Alert Destinations","description":"List the notification destinations a rule can deliver to, with id, type and delivery health. Pass the ids as destination_ids to create_alert_rule / update_alert_rule.","inputSchema":{"type":"object","properties":{"type":{"type":"string","enum":["pagerduty","webhook","discord","telegram","google-chat","microsoft-teams","incident-io","email","chat","linear"],"description":"Only destinations of this type"},"enabled_only":{"type":"boolean","description":"Only enabled destinations"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"destinations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"type":{"type":"string"},"enabled":{"type":"boolean"},"summary":{"type":"string"},"channelLabel":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastTestedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastTestError":{"anyOf":[{"type":"string"},{"type":"null"}]},"disabledReason":{"anyOf":[{"type":"string"},{"type":"null"}]},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","type","enabled","summary","channelLabel","lastTestedAt","lastTestError","disabledReason","createdAt","updatedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"type":{"type":"string"},"enabledOnly":{"type":"boolean"}},"required":["destinations","total"],"additionalProperties":true},"annotations":{"title":"List Alert Destinations","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, - {"name":"get_alert_rule","title":"Get Alert Rule","description":"Get full configuration details of a specific alert rule including thresholds, service filters, evaluation settings, and notification destinations. Use list_alert_rules to find rule IDs.","inputSchema":{"type":"object","properties":{"rule_id":{"type":"string","description":"Alert rule ID: its UUID or its REST id (alrt_\u2026)"}},"required":["rule_id"],"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"},"excludeServiceNames":{"type":"array","items":{"type":"string"}},"groupBy":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]},"minimumSampleCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"noDataBehavior":{"type":"string"},"consecutiveBreachesRequired":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"consecutiveHealthyRequired":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"renotifyIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"apdexThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"queryBuilderDraft":{"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"}]},"spaceAggregation":{"anyOf":[{"type":"string","enum":["sum","avg","min","max","count"]},{"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},{"type":"null"}]},"rawQuerySql":{"anyOf":[{"type":"string"},{"type":"null"}]},"rawQueryReducer":{"anyOf":[{"type":"string"},{"type":"null"}]},"thresholdUpper":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"notificationTitle":{"anyOf":[{"type":"string"},{"type":"null"}]},"notificationBody":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastEvaluationError":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastEvaluationErrorKind":{"anyOf":[{"type":"string"},{"type":"null"}]},"slowedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastEvaluatedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"destinations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]},"enabled":{"type":"boolean"}},"required":["id","name","type","enabled"],"additionalProperties":true}}},"required":["id","name","enabled","severity","serviceNames","environments","signalType","comparator","threshold","windowMinutes","evaluationIntervalMinutes","slowedIntervalMinutes","destinationIds","createdAt","updatedAt","excludeServiceNames","groupBy","minimumSampleCount","noDataBehavior","consecutiveBreachesRequired","consecutiveHealthyRequired","renotifyIntervalMinutes","apdexThresholdMs","queryBuilderDraft","rawQuerySql","rawQueryReducer"],"additionalProperties":true}},"required":["rule"],"additionalProperties":true},"annotations":{"title":"Get Alert Rule","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"get_alert_rule","title":"Get Alert Rule","description":"Get full configuration details of a specific alert rule including thresholds, service filters, evaluation settings, and notification destinations. Use list_alert_rules to find rule IDs.","inputSchema":{"type":"object","properties":{"rule_id":{"type":"string","description":"Alert rule ID: its UUID or its REST id (alrt_\u2026)"}},"required":["rule_id"],"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"},"excludeServiceNames":{"type":"array","items":{"type":"string"}},"groupBy":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]},"minimumSampleCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"noDataBehavior":{"type":"string"},"consecutiveBreachesRequired":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"consecutiveHealthyRequired":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"renotifyIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"apdexThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"queryBuilderDraft":{"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"}]},"spaceAggregation":{"anyOf":[{"type":"string","enum":["sum","avg","min","max","count"]},{"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},{"type":"null"}]},"rawQuerySql":{"anyOf":[{"type":"string"},{"type":"null"}]},"rawQueryReducer":{"anyOf":[{"type":"string"},{"type":"null"}]},"thresholdUpper":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"notificationTitle":{"anyOf":[{"type":"string"},{"type":"null"}]},"notificationBody":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastEvaluationError":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastEvaluationErrorKind":{"anyOf":[{"type":"string"},{"type":"null"}]},"slowedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastEvaluatedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"destinations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]},"enabled":{"type":"boolean"}},"required":["id","name","type","enabled"],"additionalProperties":true}}},"required":["id","name","enabled","severity","serviceNames","environments","signalType","comparator","threshold","windowMinutes","evaluationIntervalMinutes","slowedIntervalMinutes","destinationIds","createdAt","updatedAt","excludeServiceNames","groupBy","minimumSampleCount","noDataBehavior","consecutiveBreachesRequired","consecutiveHealthyRequired","renotifyIntervalMinutes","apdexThresholdMs","queryBuilderDraft","rawQuerySql","rawQueryReducer"],"additionalProperties":true}},"required":["rule"],"additionalProperties":true},"annotations":{"title":"Get Alert Rule","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"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"},"acknowledged":{"type":"boolean","description":"true: open incidents someone acknowledged; false: open incidents nobody has. Implies status=open"},"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"}]},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"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"},"acknowledged":{"type":"boolean"},"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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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":"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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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)."},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of destination_ids, paging step by step until acknowledged (from list_escalation_chains): its UUID or its REST id (esc_\u2026)"},"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"}]},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"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"},"acknowledged":{"type":"boolean"},"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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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":"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). escalation_chain_id adds step-by-step paging on top.","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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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."},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of destination_ids, paging step by step until acknowledged (from list_escalation_chains): its UUID or its REST id (esc_\u2026)"}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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}}"},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of the destinations (from list_escalation_chains), or \"none\" to detach it. On a rule with warning and critical tiers it sets the critical tier: its UUID or its REST id (esc_\u2026)"}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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":"acknowledge_alert_incident","title":"Acknowledge Alert Incident","description":"Mark an open alert incident as acknowledged (someone is on it) or, with acknowledged=false, clear the acknowledgement. While acknowledged, reminders pause; an escalation to a higher severity still notifies and clears it. Only open incidents; ids from list_alert_incidents.","inputSchema":{"type":"object","properties":{"incident_id":{"type":"string","description":"Alert incident ID: its UUID or its REST id (inc_\u2026)"},"acknowledged":{"type":"boolean","description":"false to unacknowledge. Default true."}},"required":["incident_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"ruleId":{"type":"string"},"ruleName":{"type":"string"},"status":{"type":"string"},"acknowledged":{"type":"boolean"},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"anyOf":[{"type":"string"},{"type":"null"}]},"changed":{"type":"boolean"}},"required":["id","ruleId","ruleName","status","acknowledged","acknowledgedAt","acknowledgedBy","changed"],"additionalProperties":true},"annotations":{"title":"Acknowledge Alert Incident","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"list_escalation_chains","title":"List Escalation Chains","description":"List escalation chains: ordered paging steps that notify destinations one step at a time until an incident is acknowledged or resolved. Shows each chain's steps, repeats and the rules using it. Attach one to a rule with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chains":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["chains","total"],"additionalProperties":true},"annotations":{"title":"List Escalation Chains","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"create_escalation_chain","title":"Create Escalation Chain","description":"Create an escalation chain: steps that each notify their destinations, then wait for an acknowledgement before the next step. A rule tier that names the chain still notifies its own destinations at once; the chain pages on top until the incident is acknowledged or resolved. Attach it with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"Chain name, e.g. 'Checkout on-call'"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["destination_ids","wait_minutes"],"additionalProperties":false}},"description":"Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":10},{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. At most 10 steps of 10 destinations"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5 (default 0)"},"description":{"type":"string","description":"Free-form notes"}},"required":["name","steps"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Create Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, + {"name":"update_escalation_chain","title":"Update Escalation Chain","description":"Change an escalation chain's name, steps, repeats or description. Pass only what changes; steps replaces every step. Escalations already running finish on the steps they started with. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to change: its UUID or its REST id (esc_\u2026)"},"name":{"type":"string","description":"New name"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["destination_ids","wait_minutes"],"additionalProperties":false}},"description":"Replaces every step. Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":10},{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. At most 10 steps of 10 destinations"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5"},"description":{"type":"string","description":"New notes"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Update Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"delete_escalation_chain","title":"Delete Escalation Chain","description":"Delete an escalation chain. Refused while a rule tier still names it: detach it with update_alert_rule escalation_chain_id=\"none\" first. Escalations already running finish. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to delete: its UUID or its REST id (esc_\u2026)"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"additionalProperties":true},"annotations":{"title":"Delete Escalation Chain","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"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"}]},"spaceAggregation":{"anyOf":[{"type":"string","enum":["sum","avg","min","max","count"]},{"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-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts index 2d446f0d4e..d7a0f6039e 100644 --- a/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts @@ -15,9 +15,12 @@ import { AlertRulesListResponse, AlertRuleUpsertRequest, AlertValidationError, + EscalationChainDocument, + EscalationChainsListResponse, OrgId, UserId, } from "@maple/domain/http" +import { encodePublicId } from "@maple/domain/http/v2" import { CreateAlertRuleOutput, GetAlertRuleOutput, @@ -31,6 +34,7 @@ import { import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" import { AlertsService } from "@maple/backend/services/alerts/AlertsService" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" +import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" import { CurrentMcpTenant } from "../../lib/query-warehouse" import { executeRegisteredMcpToolUnscoped } from "../registry" import { buildUpdatedRequest } from "../update-alert-rule" @@ -86,6 +90,7 @@ const rule = Schema.decodeSync(AlertRuleDocument)({ updatedBy: "user_tools", }) +const CHAIN_ID = "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b" const ALL_ID = "8a1c2d3e-4b5a-4c6d-8e9f-0a1b2c3d4e5f" const RAW_ID = "9b1c2d3e-4b5a-4c6d-8e9f-0a1b2c3d4e5f" const OTHER_RAW_ID = "ab1c2d3e-4b5a-4c6d-8e9f-0a1b2c3d4e5f" @@ -283,6 +288,30 @@ const layer = (seen: Seen) => }), ), } as never), + Layer.mock(EscalationChainsService)({ + list: () => + Effect.succeed( + new EscalationChainsListResponse({ + chains: [ + Schema.decodeSync(EscalationChainDocument)({ + id: CHAIN_ID, + name: "Checkout on-call", + description: null, + steps: [ + { + targets: [{ type: "destination", destinationId: DEST_ID }], + waitMinutes: 10, + }, + ], + repeatCount: 0, + ruleIds: [RULE_ID], + createdAt: NOW, + updatedAt: NOW, + }), + ], + }), + ), + }), Layer.succeed(AlertReadModelsService, { listIncidents: (_org: unknown, options: { readonly status?: string }) => { seen.incidentOptions ??= options @@ -975,4 +1004,92 @@ describe("alert tools", () => { // The one-check timing still says which interval fits. expect(text(heavy)).toContain("One check took 7.8s") }) + + it("create_alert_rule sends an escalation chain on the rule's single tier", async () => { + const seen: Seen = {} + await ok( + "create_alert_rule", + { + name: "Errors", + template: "high_error_rate", + severity: "critical", + destination_ids: [DEST_ID], + escalation_chain_id: encodePublicId("esc", CHAIN_ID), + }, + seen, + ) + expect(seen.created?.thresholds).toEqual([ + { + severity: "critical", + value: 0.05, + upper: null, + recoveryValue: null, + destinationIds: [DEST_ID], + escalationChainId: CHAIN_ID, + }, + ]) + + const bad = await run("create_alert_rule", { + name: "Errors", + template: "high_error_rate", + destination_ids: [DEST_ID], + escalation_chain_id: encodePublicId("dest", CHAIN_ID), + }) + expect(bad._tag === "Failure" ? invalidParameter(bad.failure) : undefined).toBe("escalation_chain_id") + }) + + it("update_alert_rule sets, keeps and detaches a tier's escalation chain", () => { + const chainOf = (current: AlertRuleDocument, params: Parameters[1]) => + Arr.map( + Effect.runSync(buildUpdatedRequest(current, params)).thresholds, + (tier) => tier.escalationChainId, + ) + expect(chainOf(rule, { escalation_chain_id: CHAIN_ID })).toEqual([CHAIN_ID]) + + const chained = Schema.decodeSync(AlertRuleDocument)({ + ...rule, + thresholds: [{ ...rule.thresholds[0], escalationChainId: CHAIN_ID }], + }) + expect(chainOf(chained, { threshold: 0.2 })).toEqual([CHAIN_ID]) + expect(chainOf(chained, { escalation_chain_id: "none" })).toEqual([null]) + + const tiered = Schema.decodeSync(AlertRuleDocument)({ + ...rule, + thresholds: [ + { + severity: "warning", + value: 0.02, + upper: null, + recoveryValue: null, + destinationIds: [DEST_ID], + }, + { + severity: "critical", + value: 0.05, + upper: null, + recoveryValue: null, + destinationIds: [DEST_ID], + }, + ], + }) + expect(chainOf(tiered, { escalation_chain_id: CHAIN_ID })).toEqual([undefined, CHAIN_ID]) + + const malformed = Effect.runSync( + Effect.flip(buildUpdatedRequest(rule, { escalation_chain_id: "not-a-chain" })), + ) + expect(malformed.parameter).toBe("escalation_chain_id") + }) + + it("get_alert_rule names the escalation chain a tier starts", async () => { + const chained = Schema.decodeSync(AlertRuleDocument)({ + ...rule, + thresholds: [{ ...rule.thresholds[0], escalationChainId: CHAIN_ID }], + }) + const result = await ok("get_alert_rule", { rule_id: RULE_ID }, { rules: [chained] }) + const output = Schema.decodeUnknownSync(GetAlertRuleOutput)(result.structuredContent) + expect(output.rule.escalationChains).toEqual([ + { severity: "critical", chainId: CHAIN_ID, name: "Checkout on-call" }, + ]) + expect(text(result)).toContain(`Escalation: critical: Checkout on-call (${CHAIN_ID})`) + }) }) diff --git a/apps/ai/src/mcp/tools/__tests__/escalation-chain-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/escalation-chain-tools.test.ts new file mode 100644 index 0000000000..fc8168b302 --- /dev/null +++ b/apps/ai/src/mcp/tools/__tests__/escalation-chain-tools.test.ts @@ -0,0 +1,261 @@ +/** + * The escalation chain tools through the registry: step input decoded to the service request, + * steps rendered with destination names, and service failures as input errors on the parameter. + */ +import { describe, expect, it } from "vitest" +import { Context, Effect, Layer, Schema, Array as Arr } from "effect" +import { + AlertDestinationsListResponse, + AlertValidationError, + EscalationChainDocument, + EscalationChainId, + EscalationChainInUseError, + EscalationChainNotFoundError, + EscalationChainsListResponse, + OrgId, + UserId, + type EscalationChainCreateRequest, + type EscalationChainUpdateRequest, +} from "@maple/domain/http" +import { encodePublicId } from "@maple/domain/http/v2" +import { ListEscalationChainsOutput } from "@maple/domain/mcp-outputs" +import { AlertsService } from "@maple/backend/services/alerts/AlertsService" +import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" +import { CurrentMcpTenant } from "../../lib/query-warehouse" +import { executeRegisteredMcpToolUnscoped } from "../registry" +import type { McpToolResult } from "../types" + +const CHAIN_ID = "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b" +const MISSING_ID = "9b8c7d6e-5f4a-4b3c-9d2e-1f0a9b8c7d6e" +const RULE_ID = "7f1c2d3e-4b5a-4c6d-8e9f-0a1b2c3d4e5f" +const PRIMARY = "0e1d2c3b-4a59-4687-9a0b-1c2d3e4f5a6b" +const SECONDARY = "1f2e3d4c-5b6a-4798-8a0b-2c3d4e5f6a7b" +const NOW = "2026-11-06T10:00:00.000Z" + +const chainDoc = (overrides: Partial = {}) => + Schema.decodeSync(EscalationChainDocument)({ + id: CHAIN_ID, + name: "Checkout on-call", + description: null, + steps: [ + { targets: [{ type: "destination", destinationId: PRIMARY }], waitMinutes: 10 }, + { targets: [{ type: "destination", destinationId: SECONDARY }], waitMinutes: 90 }, + ], + repeatCount: 1, + ruleIds: [RULE_ID], + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }) + +const notFound = (id: string) => + new EscalationChainNotFoundError({ + message: "Escalation chain not found", + chainId: Schema.decodeSync(EscalationChainId)(id), + }) + +interface Seen { + created?: EscalationChainCreateRequest + updated?: EscalationChainUpdateRequest + deleted?: string + /** The service refuses creates as an unknown destination. */ + rejectCreate?: boolean + /** The chain is still named by a rule. */ + inUse?: boolean +} + +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", + }), + Layer.mock(EscalationChainsService)({ + list: () => Effect.succeed(new EscalationChainsListResponse({ chains: [chainDoc()] })), + get: (_org, id) => (id === CHAIN_ID ? Effect.succeed(chainDoc()) : Effect.fail(notFound(id))), + create: (_org, _user, _roles, request) => { + seen.created = request + if (seen.rejectCreate === true) { + const message = "An escalation step names a destination that does not exist" + return Effect.fail(new AlertValidationError({ message, details: [message] })) + } + return Effect.succeed( + chainDoc({ + name: request.name, + steps: request.steps, + repeatCount: request.repeatCount ?? 0, + ruleIds: [], + }), + ) + }, + update: (_org, _user, _roles, id, request) => { + seen.updated = request + return id === CHAIN_ID + ? Effect.succeed(chainDoc(request.name === undefined ? {} : { name: request.name })) + : Effect.fail(notFound(id)) + }, + delete: (_org, _roles, id) => { + seen.deleted = id + return seen.inUse === true + ? Effect.fail( + new EscalationChainInUseError({ + message: "in use", + chainId: Schema.decodeSync(EscalationChainId)(id), + ruleIds: chainDoc().ruleIds, + ruleNames: ["Checkout errors"], + }), + ) + : Effect.void + }, + }), + Layer.mock(AlertsService)({ + listDestinations: () => + Effect.succeed( + Schema.decodeSync(AlertDestinationsListResponse)({ + destinations: Arr.map( + [ + [PRIMARY, "Primary pager", "pagerduty"], + [SECONDARY, "Ops channel", "webhook"], + ] as const, + ([id, name, type]) => ({ + id, + name, + type, + enabled: true, + summary: name, + channelLabel: null, + memberUserIds: null, + lastTestedAt: null, + lastTestError: null, + createdAt: NOW, + updatedAt: NOW, + }), + ), + }), + ), + }), + ) + +// 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 ?? "" + +describe("escalation chain tools", () => { + it("list_escalation_chains renders each step with its destinations and wait", async () => { + const result = await ok("list_escalation_chains", {}) + const output = Schema.decodeUnknownSync(ListEscalationChainsOutput)(result.structuredContent) + expect(output.chains[0]?.publicId).toBe(encodePublicId("esc", CHAIN_ID)) + expect(output.chains[0]?.steps[1]?.targets[0]?.name).toBe("Ops channel") + expect(text(result)).toContain("1. notify Primary pager (pagerduty), then wait 10m for an ack") + expect(text(result)).toContain("2. notify Ops channel (webhook), then wait 1h 30m for an ack") + expect(text(result)).toContain("repeats 1 more time") + }) + + it("create_escalation_chain decodes REST destination ids and JSON steps into the request", async () => { + const seen: Seen = {} + const result = await ok( + "create_escalation_chain", + { + name: "Checkout on-call", + steps: JSON.stringify([ + { destination_ids: [encodePublicId("dest", PRIMARY)], wait_minutes: 5 }, + { destination_ids: [SECONDARY, PRIMARY], wait_minutes: 15 }, + ]), + repeat_count: 2, + }, + seen, + ) + expect(seen.created?.steps).toEqual([ + { targets: [{ type: "destination", destinationId: PRIMARY }], waitMinutes: 5 }, + { + targets: [ + { type: "destination", destinationId: SECONDARY }, + { type: "destination", destinationId: PRIMARY }, + ], + waitMinutes: 15, + }, + ]) + expect(seen.created?.repeatCount).toBe(2) + expect(text(result)).toContain( + "2. notify Ops channel (webhook), Primary pager (pagerduty), then wait 15m", + ) + }) + + it("create_escalation_chain refuses bad steps before the service and maps an unknown destination to steps", async () => { + const seen: Seen = {} + const empty = await run("create_escalation_chain", { name: "Empty", steps: [] }, seen) + const tooLong = await run( + "create_escalation_chain", + { name: "Slow", steps: [{ destination_ids: [PRIMARY], wait_minutes: 2000 }] }, + seen, + ) + const badId = await run( + "create_escalation_chain", + { name: "Bad", steps: [{ destination_ids: ["alrt_nope"], wait_minutes: 5 }] }, + seen, + ) + for (const result of [empty, tooLong, badId]) expect(invalidParameter(result)).toBe("steps") + expect(seen.created).toBeUndefined() + + const rejected = await run( + "create_escalation_chain", + { name: "Ghost", steps: [{ destination_ids: [MISSING_ID], wait_minutes: 5 }] }, + { rejectCreate: true }, + ) + expect(invalidParameter(rejected)).toBe("steps") + }) + + it("update_escalation_chain sends only the given fields and needs at least one", async () => { + const seen: Seen = {} + await ok( + "update_escalation_chain", + { chain_id: encodePublicId("esc", CHAIN_ID), name: "Renamed" }, + seen, + ) + expect(seen.updated).toEqual({ name: "Renamed" }) + + const nothing = await run("update_escalation_chain", { chain_id: CHAIN_ID }, {}) + expect(invalidParameter(nothing)).toBe("steps") + const missing = await run("update_escalation_chain", { chain_id: MISSING_ID, repeat_count: 0 }, {}) + expect(invalidParameter(missing)).toBe("chain_id") + }) + + it("delete_escalation_chain names the rules that still use a chain", async () => { + const seen: Seen = {} + const result = await ok("delete_escalation_chain", { chain_id: CHAIN_ID }, seen) + expect(seen.deleted).toBe(CHAIN_ID) + expect(text(result)).toContain("Checkout on-call") + + const inUse = await run("delete_escalation_chain", { chain_id: CHAIN_ID }, { inUse: true }) + expect(invalidParameter(inUse)).toBe("chain_id") + expect(inUse._tag === "Failure" ? inUse.failure.message : "").toContain( + `"Checkout errors" (${RULE_ID})`, + ) + + const missing = await run("delete_escalation_chain", { chain_id: MISSING_ID }, {}) + expect(invalidParameter(missing)).toBe("chain_id") + }) +}) diff --git a/apps/ai/src/mcp/tools/create-alert-rule.ts b/apps/ai/src/mcp/tools/create-alert-rule.ts index e46bf748e7..83ef3b4031 100644 --- a/apps/ai/src/mcp/tools/create-alert-rule.ts +++ b/apps/ai/src/mcp/tools/create-alert-rule.ts @@ -142,6 +142,10 @@ export const CreateAlertRuleParameters = Schema.Struct({ "Custom notification body template (Markdown). Supports {{ variable }} substitution and " + '{{#if key}}…{{/if}} blocks, e.g. "*Severity:* {{ severity }}\\n*Observed:* {{ observed.summary }}". Omit for the built-in format.', ), + escalation_chain_id: P.optionalResourceId( + "escalationChain", + "Escalation chain an incident starts on top of destination_ids, paging step by step until acknowledged (from list_escalation_chains)", + ), }) const invalid = (message: string, parameter: string, example?: string) => @@ -229,10 +233,16 @@ export const buildAlertRuleRequest = Effect.fnUntraced(function* ( // service scope are mutually exclusive, so an inherited groupBy must step aside // for a caller-supplied service; an explicit group_by is left to be rejected on its own terms. const effectiveGroupBy = groupBy ?? (services === undefined ? template?.groupBy : undefined) + const severity = params.severity ?? "warning" + // A chain lives on a tier, so a rule with one is sent as its single tier. + const escalationChainId = + params.escalation_chain_id === undefined + ? undefined + : yield* P.decodeResourceId("escalationChain", "escalation_chain_id")(params.escalation_chain_id) return { name: params.name, - severity: params.severity ?? "warning", + severity, signalType, comparator, threshold, @@ -241,6 +251,20 @@ export const buildAlertRuleRequest = Effect.fnUntraced(function* ( ? undefined : { evaluationIntervalMinutes: params.evaluation_interval_minutes }), destinationIds: params.destination_ids, + ...(escalationChainId === undefined + ? undefined + : { + thresholds: [ + { + severity, + value: threshold, + upper: null, + recoveryValue: null, + destinationIds: params.destination_ids, + escalationChainId, + }, + ], + }), ...(params.enabled === undefined ? undefined : { enabled: params.enabled }), ...(services === undefined ? undefined : { serviceNames: services }), ...(params.environments === undefined || params.environments.length === 0 @@ -292,7 +316,7 @@ export function registerCreateAlertRuleTool(server: McpToolRegistrar) { // repeating them here cost tokens twice for one fact. 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).", + "destination_ids is required (at least one, from list_alert_destinations). escalation_chain_id adds step-by-step paging on top.", parameters: CreateAlertRuleParameters, aliases: { service_names: "services" }, output: CreateAlertRuleOutput, diff --git a/apps/ai/src/mcp/tools/create-escalation-chain.ts b/apps/ai/src/mcp/tools/create-escalation-chain.ts new file mode 100644 index 0000000000..f1742a689d --- /dev/null +++ b/apps/ai/src/mcp/tools/create-escalation-chain.ts @@ -0,0 +1,76 @@ +import { McpInvalidInputError, type McpToolRegistrar } from "./types" +import { Effect, Schema } from "effect" +import { EscalationChainCreateRequest } from "@maple/domain/http" +import { EscalationChainMutationOutput } from "@maple/domain/mcp-outputs" +import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import { + chainBlocks, + createChainErrors, + destinationDirectory, + StepsInput, + stepsParameterDescription, + toEscalationChainRow, + toStepsRequest, +} from "../lib/escalation-chains" +import * as P from "../lib/params" +import { doc } from "../lib/tool-doc" + +const decodeCreateRequest = Schema.decodeUnknownEffect(EscalationChainCreateRequest) + +export function registerCreateEscalationChainTool(server: McpToolRegistrar) { + server.define({ + name: "create_escalation_chain", + title: "Create Escalation Chain", + description: + "Create an escalation chain: steps that each notify their destinations, then wait for an acknowledgement before the next step. " + + "A rule tier that names the chain still notifies its own destinations at once; the chain pages on top until the incident is acknowledged or resolved. " + + "Attach it with create_alert_rule / update_alert_rule escalation_chain_id.", + parameters: Schema.Struct({ + name: P.text("Chain name, e.g. 'Checkout on-call'"), + steps: P.json(StepsInput, stepsParameterDescription), + repeat_count: P.optionalNumber( + "Extra passes over the whole chain after the first, 0 to 5 (default 0)", + ), + description: P.optionalText("Free-form notes"), + }), + output: EscalationChainMutationOutput, + hints: { readOnly: false, destructive: false, idempotent: false }, + phrases: ["Creating an escalation chain"], + handler: Effect.fn("McpTool.createEscalationChain")(function* (params) { + const steps = yield* toStepsRequest(params.steps) + const request = yield* decodeCreateRequest({ + name: params.name, + steps, + ...(params.repeat_count === undefined ? undefined : { repeatCount: params.repeat_count }), + ...(params.description === undefined ? undefined : { description: params.description }), + }).pipe( + Effect.mapError( + (error) => + new McpInvalidInputError({ + message: `Invalid escalation chain: ${String(error)}`, + parameter: "steps", + }), + ), + ) + const tenant = yield* CurrentMcpTenant + const chains = yield* EscalationChainsService + const chain = yield* chains + .create(tenant.orgId, tenant.userId, tenant.roles, request) + .pipe(Effect.catchTags(createChainErrors)) + const destinations = yield* destinationDirectory(tenant.orgId) + return { chain: toEscalationChainRow(chain, destinations) } + }), + render: (output) => ({ + title: "Escalation Chain Created", + blocks: chainBlocks(output.chain), + next: [ + doc.next( + "list_alert_rules", + {}, + `attach it with update_alert_rule escalation_chain_id="${output.chain.id}"`, + ), + ], + }), + }) +} diff --git a/apps/ai/src/mcp/tools/delete-escalation-chain.ts b/apps/ai/src/mcp/tools/delete-escalation-chain.ts new file mode 100644 index 0000000000..73dab98df8 --- /dev/null +++ b/apps/ai/src/mcp/tools/delete-escalation-chain.ts @@ -0,0 +1,52 @@ +import type { McpToolRegistrar } from "./types" +import { Effect, Schema } from "effect" +import { DeleteEscalationChainOutput } from "@maple/domain/mcp-outputs" +import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import { deleteChainErrors, updateChainErrors } from "../lib/escalation-chains" +import * as P from "../lib/params" +import { doc } from "../lib/tool-doc" + +export function registerDeleteEscalationChainTool(server: McpToolRegistrar) { + server.define({ + name: "delete_escalation_chain", + title: "Delete Escalation Chain", + description: + 'Delete an escalation chain. Refused while a rule tier still names it: detach it with update_alert_rule escalation_chain_id="none" first. ' + + "Escalations already running finish. Ids from list_escalation_chains.", + parameters: Schema.Struct({ + chain_id: P.resourceId("escalationChain", "Escalation chain to delete"), + }), + output: DeleteEscalationChainOutput, + hints: { readOnly: false, destructive: true, idempotent: false }, + phrases: ["Deleting an escalation chain"], + handler: Effect.fn("McpTool.deleteEscalationChain")(function* (params) { + const chainId = yield* P.decodeResourceId("escalationChain", "chain_id")(params.chain_id) + const tenant = yield* CurrentMcpTenant + const chains = yield* EscalationChainsService + // Read first so the reply names what was deleted. + const chain = yield* chains.get(tenant.orgId, chainId).pipe( + Effect.catchTags({ + "@maple/http/errors/EscalationChainNotFoundError": + updateChainErrors["@maple/http/errors/EscalationChainNotFoundError"], + "@maple/http/errors/AlertPersistenceError": + deleteChainErrors["@maple/http/errors/AlertPersistenceError"], + }), + ) + yield* chains + .delete(tenant.orgId, tenant.roles, chainId) + .pipe(Effect.catchTags(deleteChainErrors)) + return { id: chain.id, name: chain.name } + }), + render: (output) => ({ + title: "Escalation Chain Deleted", + blocks: [ + doc.fields([ + ["ID", output.id], + ["Name", output.name], + ]), + ], + next: [doc.next("list_escalation_chains", {}, "the remaining chains")], + }), + }) +} diff --git a/apps/ai/src/mcp/tools/get-alert-rule.ts b/apps/ai/src/mcp/tools/get-alert-rule.ts index f1d4b29b9d..ea74ed8d8a 100644 --- a/apps/ai/src/mcp/tools/get-alert-rule.ts +++ b/apps/ai/src/mcp/tools/get-alert-rule.ts @@ -1,13 +1,15 @@ import type { McpToolRegistrar } from "./types" import { toMcpHttpError } from "../lib/map-http-error" -import { Effect, Schema, Array as Arr } from "effect" +import { Effect, Option, Schema, Array as Arr } from "effect" import { GetAlertRuleOutput } from "@maple/domain/mcp-outputs" import { CurrentMcpTenant } from "../lib/query-warehouse" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" import { AlertsService } from "@maple/backend/services/alerts/AlertsService" +import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" import { formatCheckInterval, formatCondition, + formatEscalationChains, ruleConfigWarnings, ruleNotFound, slowedNotice, @@ -71,9 +73,30 @@ export function registerGetAlertRuleTool(server: McpToolRegistrar) { } }) + const row = toAlertRuleRow(rule) + // Chain names decorate the ids too; only a rule that uses a chain reads them. + const escalationChains = + row.escalationChains === undefined + ? undefined + : yield* (yield* EscalationChainsService).list(tenant.orgId).pipe( + Effect.map((r) => + Arr.map(row.escalationChains ?? [], (c) => ({ + ...c, + name: Option.getOrNull( + Option.map( + Arr.findFirst(r.chains, (chain) => chain.id === c.chainId), + (chain) => chain.name, + ), + ), + })), + ), + Effect.orElseSucceed(() => row.escalationChains), + ) + return { rule: { - ...toAlertRuleRow(rule), + ...row, + ...(escalationChains === undefined ? undefined : { escalationChains }), excludeServiceNames: [...rule.excludeServiceNames], groupBy: rule.groupBy ? [...rule.groupBy] : null, minimumSampleCount: rule.minimumSampleCount, @@ -107,6 +130,7 @@ export function registerGetAlertRuleTool(server: McpToolRegistrar) { ["Window", `${rule.windowMinutes}m, checked ${formatCheckInterval(rule)}`], ["Slowed down at", rule.slowedAt ?? undefined], ["Last evaluated", rule.lastEvaluatedAt ?? undefined], + ["Escalation", formatEscalationChains(rule.escalationChains)], ["Last evaluation error", rule.lastEvaluationError ?? undefined], [ "Last error kind", diff --git a/apps/ai/src/mcp/tools/list-escalation-chains.ts b/apps/ai/src/mcp/tools/list-escalation-chains.ts new file mode 100644 index 0000000000..11594e75b0 --- /dev/null +++ b/apps/ai/src/mcp/tools/list-escalation-chains.ts @@ -0,0 +1,64 @@ +import type { McpToolRegistrar } from "./types" +import { Array as Arr, Effect, Schema } from "effect" +import { ListEscalationChainsOutput } from "@maple/domain/mcp-outputs" +import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import { toMcpHttpError } from "../lib/map-http-error" +import { + destinationDirectory, + formatRepeats, + formatSteps, + toEscalationChainRow, +} from "../lib/escalation-chains" +import { doc } from "../lib/tool-doc" + +export function registerListEscalationChainsTool(server: McpToolRegistrar) { + server.define({ + name: "list_escalation_chains", + title: "List Escalation Chains", + description: + "List escalation chains: ordered paging steps that notify destinations one step at a time until an incident is acknowledged or resolved. " + + "Shows each chain's steps, repeats and the rules using it. Attach one to a rule with create_alert_rule / update_alert_rule escalation_chain_id.", + parameters: Schema.Struct({}), + output: ListEscalationChainsOutput, + hints: { readOnly: true }, + phrases: ["Listing escalation chains"], + handler: Effect.fn("McpTool.listEscalationChains")(function* () { + const tenant = yield* CurrentMcpTenant + const chains = yield* EscalationChainsService + const result = yield* chains + .list(tenant.orgId) + .pipe(Effect.mapError(toMcpHttpError("list_escalation_chains"))) + const destinations = result.chains.length === 0 ? [] : yield* destinationDirectory(tenant.orgId) + yield* Effect.annotateCurrentSpan({ + orgId: tenant.orgId, + "result.rowCount": result.chains.length, + }) + return { + chains: Arr.map(result.chains, (chain) => toEscalationChainRow(chain, destinations)), + total: result.chains.length, + } + }), + render: (output) => ({ + title: "Escalation Chains", + ...(output.chains.length === 0 + ? { + empty: { + message: + "No escalation chains yet. Create one with create_escalation_chain from destinations in list_alert_destinations.", + }, + } + : undefined), + blocks: Arr.flatMap(output.chains, (chain) => [ + doc.heading(`${chain.name} (${chain.id})`), + doc.text( + `${formatSteps(chain)}\nThe chain ${formatRepeats(chain.repeatCount)}. Used by ${chain.ruleIds.length} rule${chain.ruleIds.length === 1 ? "" : "s"}.`, + ), + ]), + next: + output.chains.length === 0 + ? [doc.next("list_alert_destinations", {}, "the destinations a step can notify")] + : [doc.next("list_alert_rules", {}, "attach a chain to a rule with update_alert_rule")], + }), + }) +} diff --git a/apps/ai/src/mcp/tools/mutating.ts b/apps/ai/src/mcp/tools/mutating.ts index f97be328e6..f21c8a9d46 100644 --- a/apps/ai/src/mcp/tools/mutating.ts +++ b/apps/ai/src/mcp/tools/mutating.ts @@ -19,6 +19,9 @@ export const MUTATING_TOOL_NAMES: ReadonlySet = new Set([ "update_alert_rule", "delete_alert_rule", "acknowledge_alert_incident", + "create_escalation_chain", + "update_escalation_chain", + "delete_escalation_chain", // 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 2d974ac9f0..08718ab6db 100644 --- a/apps/ai/src/mcp/tools/registry.ts +++ b/apps/ai/src/mcp/tools/registry.ts @@ -11,6 +11,10 @@ import { registerPreviewAlertRuleTool } from "./preview-alert-rule" import { registerUpdateAlertRuleTool } from "./update-alert-rule" import { registerDeleteAlertRuleTool } from "./delete-alert-rule" import { registerAcknowledgeAlertIncidentTool } from "./acknowledge-alert-incident" +import { registerListEscalationChainsTool } from "./list-escalation-chains" +import { registerCreateEscalationChainTool } from "./create-escalation-chain" +import { registerUpdateEscalationChainTool } from "./update-escalation-chain" +import { registerDeleteEscalationChainTool } from "./delete-escalation-chain" import { registerCreateDashboardTool } from "./create-dashboard" import { registerDescribeDashboardSchemaTool } from "./describe-dashboard-schema" import { registerDiagnoseServiceTool } from "./diagnose-service" @@ -431,6 +435,10 @@ const collectMapleToolDefinitions = (): ReadonlyArray => { registerUpdateAlertRuleTool(registrar) registerDeleteAlertRuleTool(registrar) registerAcknowledgeAlertIncidentTool(registrar) + registerListEscalationChainsTool(registrar) + registerCreateEscalationChainTool(registrar) + registerUpdateEscalationChainTool(registrar) + registerDeleteEscalationChainTool(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 340927704f..2bf4b85383 100644 --- a/apps/ai/src/mcp/tools/runtime-requirements.ts +++ b/apps/ai/src/mcp/tools/runtime-requirements.ts @@ -4,6 +4,7 @@ import type { AlertsService } from "@maple/backend/services/alerts/AlertsService import type { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" import type { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import type { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" +import type { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" 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" @@ -48,6 +49,7 @@ export type McpToolRuntimeRequirements = | AlertReadModelsService | AlertIncidentAckService | AlertRulesService + | EscalationChainsService | DashboardPersistenceService | ErrorActorsService | ErrorIssueReadModelsService diff --git a/apps/ai/src/mcp/tools/update-alert-rule.ts b/apps/ai/src/mcp/tools/update-alert-rule.ts index 325300eebe..7101584acb 100644 --- a/apps/ai/src/mcp/tools/update-alert-rule.ts +++ b/apps/ai/src/mcp/tools/update-alert-rule.ts @@ -1,5 +1,5 @@ import { McpInvalidInputError, type McpToolRegistrar } from "./types" -import { Array as Arr, Effect, Schema } from "effect" +import { Array as Arr, Effect, Option, Schema, String as Str } from "effect" import { UpdateAlertRuleOutput } from "@maple/domain/mcp-outputs" import { toMcpHttpError } from "../lib/map-http-error" import { CurrentMcpTenant } from "../lib/query-warehouse" @@ -15,6 +15,7 @@ import { type AlertComparator, type AlertRuleDocument, type AlertThreshold, + type EscalationChainId, } from "@maple/domain/http" import { MAX_RAW_SQL_ALERT_GROUPS } from "@maple/domain/raw-sql" import { @@ -102,6 +103,10 @@ export const UpdateAlertRuleParameters = Schema.Struct({ notification_body: P.optionalText( "Notification body template (Markdown) with {{ variable }} and {{#if key}}...{{/if}}", ), + escalation_chain_id: P.optionalResourceId( + "escalationChain", + 'Escalation chain an incident starts on top of the destinations (from list_escalation_chains), or "none" to detach it. On a rule with warning and critical tiers it sets the critical tier', + ), }) /** @@ -129,7 +134,17 @@ export const buildUpdatedRequest = Effect.fnUntraced(function* ( const signalType = params.signal_type ?? current.signalType const comparator = params.comparator ?? current.comparator const keepsRawQuery = signalType === "raw_query" - const thresholds = yield* mergeThresholds(current, params, comparator) + const chain = + params.escalation_chain_id === undefined + ? undefined + : Str.toLowerCase(params.escalation_chain_id) === "none" + ? null + : yield* P.decodeResourceId( + "escalationChain", + "escalation_chain_id", + )(params.escalation_chain_id) + const merged = yield* mergeThresholds(current, params, comparator) + const thresholds = withEscalationChain(merged, chain) return { name: params.name ?? current.name, @@ -225,6 +240,17 @@ const mergeThresholds = ( ) } +/** Sets the chain on the only tier, or on the critical one of two; `undefined` keeps every tier's. */ +const withEscalationChain = ( + tiers: Arr.NonEmptyReadonlyArray, + chain: EscalationChainId | null | undefined, +): Arr.NonEmptyReadonlyArray => { + if (chain === undefined) return tiers + const critical = Arr.findLastIndex(tiers, (tier) => tier.severity === "critical") + const target = tiers.length === 1 ? 0 : Option.getOrElse(critical, () => tiers.length - 1) + return Arr.map(tiers, (tier, index) => (index === target ? { ...tier, escalationChainId: chain } : tier)) +} + /** `destination_ids` replaces; add/remove edit the current (or replaced) list in place. */ const mergeDestinations = ( current: ReadonlyArray, diff --git a/apps/ai/src/mcp/tools/update-escalation-chain.ts b/apps/ai/src/mcp/tools/update-escalation-chain.ts new file mode 100644 index 0000000000..df29eb0a9a --- /dev/null +++ b/apps/ai/src/mcp/tools/update-escalation-chain.ts @@ -0,0 +1,80 @@ +import { McpInvalidInputError, type McpToolRegistrar } from "./types" +import { Effect, Schema } from "effect" +import { EscalationChainUpdateRequest } from "@maple/domain/http" +import { EscalationChainMutationOutput } from "@maple/domain/mcp-outputs" +import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import { + chainBlocks, + destinationDirectory, + StepsInput, + stepsParameterDescription, + toEscalationChainRow, + toStepsRequest, + updateChainErrors, +} from "../lib/escalation-chains" +import * as P from "../lib/params" +import { doc } from "../lib/tool-doc" + +const decodeUpdateRequest = Schema.decodeUnknownEffect(EscalationChainUpdateRequest) + +export function registerUpdateEscalationChainTool(server: McpToolRegistrar) { + server.define({ + name: "update_escalation_chain", + title: "Update Escalation Chain", + description: + "Change an escalation chain's name, steps, repeats or description. Pass only what changes; steps replaces every step. " + + "Escalations already running finish on the steps they started with. Ids from list_escalation_chains.", + parameters: Schema.Struct({ + chain_id: P.resourceId("escalationChain", "Escalation chain to change"), + name: P.optionalText("New name"), + steps: P.optionalJson(StepsInput, `Replaces every step. ${stepsParameterDescription}`), + repeat_count: P.optionalNumber("Extra passes over the whole chain after the first, 0 to 5"), + description: P.optionalText("New notes"), + }), + output: EscalationChainMutationOutput, + hints: { readOnly: false, destructive: false, idempotent: true }, + phrases: ["Updating an escalation chain"], + handler: Effect.fn("McpTool.updateEscalationChain")(function* (params) { + const chainId = yield* P.decodeResourceId("escalationChain", "chain_id")(params.chain_id) + const steps = params.steps === undefined ? undefined : yield* toStepsRequest(params.steps) + if ( + params.name === undefined && + steps === undefined && + params.repeat_count === undefined && + params.description === undefined + ) { + return yield* new McpInvalidInputError({ + message: "Nothing to change: pass name, steps, repeat_count or description.", + parameter: "steps", + }) + } + const request = yield* decodeUpdateRequest({ + ...(params.name === undefined ? undefined : { name: params.name }), + ...(steps === undefined ? undefined : { steps }), + ...(params.repeat_count === undefined ? undefined : { repeatCount: params.repeat_count }), + ...(params.description === undefined ? undefined : { description: params.description }), + }).pipe( + Effect.mapError( + (error) => + new McpInvalidInputError({ + message: `Invalid escalation chain: ${String(error)}`, + parameter: "steps", + }), + ), + ) + const tenant = yield* CurrentMcpTenant + const chains = yield* EscalationChainsService + const chain = yield* chains + .update(tenant.orgId, tenant.userId, tenant.roles, chainId, request) + .pipe(Effect.catchTags(updateChainErrors)) + const destinations = yield* destinationDirectory(tenant.orgId) + return { chain: toEscalationChainRow(chain, destinations) } + }), + render: (output) => ({ + title: "Escalation Chain Updated", + blocks: chainBlocks(output.chain), + next: [doc.next("list_escalation_chains", {}, "every chain and the rules using it")], + }), + }) +} diff --git a/apps/ai/test/mcp/unimplemented-tool-services.ts b/apps/ai/test/mcp/unimplemented-tool-services.ts index ce8079677c..e8c89ce3ce 100644 --- a/apps/ai/test/mcp/unimplemented-tool-services.ts +++ b/apps/ai/test/mcp/unimplemented-tool-services.ts @@ -7,6 +7,7 @@ import { AlertsService } from "@maple/backend/services/alerts/AlertsService" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" +import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" import { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService" import { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService" import { ErrorIssueReadModelsService } from "@maple/backend/services/errors/ErrorIssueReadModelsService" @@ -44,6 +45,7 @@ export const unimplementedMcpToolServices = Layer.mergeAll( Layer.mock(AlertReadModelsService)({}), Layer.mock(AlertIncidentAckService)({}), Layer.mock(AlertRulesService)({}), + Layer.mock(EscalationChainsService)({}), Layer.mock(DashboardPersistenceService)({}), Layer.mock(ErrorActorsService)({}), Layer.mock(ErrorIssueReadModelsService)({}), diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index 9db81c6bf2..5c05555364 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -24,7 +24,7 @@ import { UserId, WarehouseQuotaExceededError, } from "@maple/domain/http" -import { MapleApiV2 } from "@maple/domain/http/v2" +import { encodePublicId, 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" @@ -645,6 +645,7 @@ describe("v2 alerts over HTTP", () => { upper: null, recovery_value: 0.015, destination_ids: [destination.body.id], + escalation_chain_id: null, }, { severity: "critical", @@ -652,6 +653,7 @@ describe("v2 alerts over HTTP", () => { upper: null, recovery_value: null, destination_ids: [pager.body.id], + escalation_chain_id: null, }, ] const created = await harness.request("POST", "/v2/alerts/rules", key.secret, { @@ -732,6 +734,7 @@ describe("v2 alerts over HTTP", () => { upper: null, recovery_value: null, destination_ids: [destination.body.id], + escalation_chain_id: null, }, ]) @@ -1289,4 +1292,173 @@ describe("v2 alerts over HTTP", () => { await harness.dispose() }) + + const createWebhook = (harness: ReturnType, token: string, name: string) => + harness.request("POST", "/v2/alerts/destinations", token, { + type: "webhook", + name, + url: `https://example.com/hooks/${name}`, + }) + + it("runs escalation chain CRUD and refuses to delete a chain a rule tier names", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(["alerts:write"]) + const primary = await createWebhook(harness, key.secret, "primary") + const secondary = await createWebhook(harness, key.secret, "secondary") + const steps = [ + { targets: [{ type: "destination", destination_id: primary.body.id }], wait_minutes: 10 }, + { targets: [{ type: "destination", destination_id: secondary.body.id }], wait_minutes: 15 }, + ] + + const created = await harness.request("POST", "/v2/alerts/escalation_chains", key.secret, { + name: "Checkout on-call", + steps, + repeat_count: 1, + }) + expect(created.status, JSON.stringify(created.body)).toBe(200) + expect(created.body).toMatchObject({ + object: "escalation_chain", + name: "Checkout on-call", + description: null, + steps, + repeat_count: 1, + rule_ids: [], + }) + expect(created.body.id).toMatch(/^esc_/) + const chainId: string = created.body.id + + const listed = await harness.request("GET", "/v2/alerts/escalation_chains", key.secret) + expect(listed.status).toBe(200) + expect(listed.body).toMatchObject({ object: "list", has_more: false }) + expect(listed.body.data.map((chain: { id: string }) => chain.id)).toEqual([chainId]) + + const rule = await harness.request("POST", "/v2/alerts/rules", key.secret, { + name: "Checkout errors", + signal_type: "error_rate", + comparator: "gt", + window_minutes: 5, + thresholds: [ + { + severity: "critical", + value: 0.05, + destination_ids: [primary.body.id], + escalation_chain_id: chainId, + }, + ], + }) + expect(rule.status, JSON.stringify(rule.body)).toBe(200) + expect(rule.body.thresholds[0].escalation_chain_id).toBe(chainId) + + // A PATCH that leaves the tiers alone keeps the chain on them. + const toggled = await harness.request("PATCH", `/v2/alerts/rules/${rule.body.id}`, key.secret, { + enabled: false, + }) + expect(toggled.status).toBe(200) + expect(toggled.body.thresholds[0].escalation_chain_id).toBe(chainId) + + const fetched = await harness.request("GET", `/v2/alerts/escalation_chains/${chainId}`, key.secret) + expect(fetched.status).toBe(200) + expect(fetched.body.rule_ids).toEqual([rule.body.id]) + + const renamed = await harness.request( + "PATCH", + `/v2/alerts/escalation_chains/${chainId}`, + key.secret, + { + name: "Checkout paging", + repeat_count: 0, + steps: [steps[1]], + }, + ) + expect(renamed.status, JSON.stringify(renamed.body)).toBe(200) + expect(renamed.body).toMatchObject({ name: "Checkout paging", repeat_count: 0, steps: [steps[1]] }) + + const inUse = await harness.request("DELETE", `/v2/alerts/escalation_chains/${chainId}`, key.secret) + expect(inUse.status).toBe(409) + expect(inUse.body.error.code).toBe("escalation_chain_in_use") + + const detached = await harness.request("PATCH", `/v2/alerts/rules/${rule.body.id}`, key.secret, { + thresholds: [ + { + severity: "critical", + value: 0.05, + destination_ids: [primary.body.id], + escalation_chain_id: null, + }, + ], + }) + expect(detached.status).toBe(200) + expect(detached.body.thresholds[0].escalation_chain_id).toBeNull() + + const deleted = await harness.request("DELETE", `/v2/alerts/escalation_chains/${chainId}`, key.secret) + expect(deleted.status, JSON.stringify(deleted.body)).toBe(200) + expect(deleted.body).toEqual({ id: chainId, object: "escalation_chain", deleted: true }) + + const gone = await harness.request("GET", `/v2/alerts/escalation_chains/${chainId}`, key.secret) + expect(gone.status).toBe(404) + expect(gone.body.error.code).toBe("escalation_chain_not_found") + + await harness.dispose() + }) + + it("rejects escalation chains with unknown destinations or bad steps, and rules naming an unknown chain", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(["alerts:write"]) + const primary = await createWebhook(harness, key.secret, "primary") + const post = (body: unknown) => + harness.request("POST", "/v2/alerts/escalation_chains", key.secret, body) + const step = (destinationId: string, waitMinutes = 10) => ({ + targets: [{ type: "destination", destination_id: destinationId }], + wait_minutes: waitMinutes, + }) + + const unknownDestination = await post({ + name: "Ghost", + steps: [step(encodePublicId("dest", "0b8f7c1e-2f61-4f1e-9d2a-3c4b5d6e7f80"))], + }) + const noSteps = await post({ name: "Empty", steps: [] }) + const noWait = await post({ name: "Instant", steps: [step(primary.body.id, 0)] }) + const tooManyRepeats = await post({ name: "Loop", steps: [step(primary.body.id)], repeat_count: 6 }) + for (const response of [unknownDestination, noSteps, noWait, tooManyRepeats]) { + expect(response.status, JSON.stringify(response.body)).toBe(400) + expect(response.body.error.type).toBe("invalid_request_error") + } + + const unknownChain = encodePublicId("esc", "0b8f7c1e-2f61-4f1e-9d2a-3c4b5d6e7f80") + const missing = await harness.request( + "PATCH", + `/v2/alerts/escalation_chains/${unknownChain}`, + key.secret, + { + name: "Nope", + }, + ) + expect(missing.status).toBe(404) + + const rule = await harness.request("POST", "/v2/alerts/rules", key.secret, { + name: "Unknown chain", + signal_type: "error_rate", + comparator: "gt", + window_minutes: 5, + thresholds: [ + { + severity: "critical", + value: 0.05, + destination_ids: [primary.body.id], + escalation_chain_id: unknownChain, + }, + ], + }) + expect(rule.status).toBe(400) + expect(rule.body.error.type).toBe("invalid_request_error") + + const readOnly = await harness.bootstrapKey(["alerts:read"]) + const denied = await harness.request("POST", "/v2/alerts/escalation_chains", readOnly.secret, { + name: "Denied", + steps: [step(primary.body.id)], + }) + expect(denied.status).toBe(403) + + await harness.dispose() + }) }) diff --git a/apps/api/src/routes/v2/escalation-chains.http.ts b/apps/api/src/routes/v2/escalation-chains.http.ts new file mode 100644 index 0000000000..824bdab589 --- /dev/null +++ b/apps/api/src/routes/v2/escalation-chains.http.ts @@ -0,0 +1,141 @@ +import { HttpApiBuilder } from "effect/http-api" +import { + CurrentTenant, + EscalationChainCreateRequest, + EscalationChainUpdateRequest, + type EscalationChainDocument, + type EscalationStep, +} from "@maple/domain/http" +import type { + V2EscalationChain, + V2EscalationChainDeleteResponse, + V2EscalationChainUpdateParams, + V2EscalationStep, +} from "@maple/domain/http/v2" +import { MapleApiV2, paginateArray, timestamp } from "@maple/domain/http/v2" +import { Array as Arr, Effect } from "effect" +import { auditDiff } from "@/routes/v2/audit-changes" +import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService" +import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" + +const chainAuditDiff = auditDiff({ + fields: ["name", "description", "steps", "repeat_count"], +}) + +const toV2Step = (step: EscalationStep): V2EscalationStep => ({ + targets: Arr.map(step.targets, (target) => ({ type: target.type, destination_id: target.destinationId })), + wait_minutes: step.waitMinutes, +}) + +const fromV2Step = (step: V2EscalationStep): EscalationStep => ({ + targets: Arr.map(step.targets, (target) => ({ type: target.type, destinationId: target.destination_id })), + waitMinutes: step.wait_minutes, +}) + +export const toV2EscalationChain = (doc: EscalationChainDocument): V2EscalationChain => ({ + id: doc.id, + object: "escalation_chain", + name: doc.name, + description: doc.description, + steps: Arr.map(doc.steps, toV2Step), + repeat_count: doc.repeatCount, + rule_ids: doc.ruleIds, + created_at: timestamp(doc.createdAt), + updated_at: timestamp(doc.updatedAt), +}) + +export const HttpV2EscalationChainsLive = HttpApiBuilder.group(MapleApiV2, "escalationChains", (handlers) => + Effect.gen(function* () { + const chains = yield* EscalationChainsService + + return handlers + .handle("list", ({ query }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + // The service returns the org's whole (capped) set, so pages are cut from it. + const response = yield* chains.list(tenant.orgId) + const page = yield* paginateArray(Arr.map(response.chains, toV2EscalationChain), query) + return { object: "list" as const, ...page } + }), + ) + .handle("retrieve", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const chain = yield* chains.get(tenant.orgId, params.id) + return toV2EscalationChain(chain) + }), + ) + .handle("create", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const created = yield* chains.create( + tenant.orgId, + tenant.userId, + tenant.roles, + new EscalationChainCreateRequest({ + name: payload.name, + ...(payload.description === undefined + ? undefined + : { description: payload.description }), + steps: Arr.map(payload.steps, fromV2Step), + ...(payload.repeat_count === undefined + ? undefined + : { repeatCount: payload.repeat_count }), + }), + ) + yield* recordHttpAudit("escalation_chain.created", { + resourceId: created.id, + metadata: { name: created.name, steps: created.steps.length }, + }) + return toV2EscalationChain(created) + }), + ) + .handle("update", ({ params, payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const current = yield* chains.get(tenant.orgId, params.id) + const updated = yield* chains.update( + tenant.orgId, + tenant.userId, + tenant.roles, + params.id, + new EscalationChainUpdateRequest({ + ...(payload.name === undefined ? undefined : { name: payload.name }), + ...(payload.description === undefined + ? undefined + : { description: payload.description }), + ...(payload.steps === undefined + ? undefined + : { steps: Arr.map(payload.steps, fromV2Step) }), + ...(payload.repeat_count === undefined + ? undefined + : { repeatCount: payload.repeat_count }), + }), + ) + yield* recordHttpAudit("escalation_chain.updated", { + resourceId: updated.id, + changes: chainAuditDiff( + payload, + toV2EscalationChain(current), + toV2EscalationChain(updated), + ), + metadata: { name: updated.name }, + }) + return toV2EscalationChain(updated) + }), + ) + .handle("delete", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* chains.delete(tenant.orgId, tenant.roles, params.id) + yield* recordHttpAudit("escalation_chain.deleted", { resourceId: params.id }) + const deleted: V2EscalationChainDeleteResponse = { + id: params.id, + object: "escalation_chain", + deleted: true, + } + return deleted + }), + ) + }), +) diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index 33029d908c..3ab4aa35fa 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -7,6 +7,7 @@ import { AlertDestinationsService } from "@maple/backend/services/alerts/AlertDe import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" +import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" import { AnomalyDetectionService } from "@maple/backend/services/alerts/AnomalyDetectionService" import { ErrorsService } from "@maple/backend/services/errors/ErrorsService" import { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService" @@ -46,6 +47,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 { HttpV2EscalationChainsLive } from "./escalation-chains.http" import { HttpV2AlertRulesLive } from "./alert-rules.http" import { HttpV2ApiKeysLive } from "./api-keys.http" import { HttpV2AttributeMappingsLive } from "./attribute-mappings.http" @@ -187,6 +189,8 @@ const v2GroupLayersExceptOnboardingChecklist = ( HttpV2AlertIncidentsLive.pipe( Layer.provide(Layer.merge(AlertIncidentAckService.layer, OrgMembersServiceStubLayer)), ), + // Real service: it needs only the Database every harness already provides. + HttpV2EscalationChainsLive.pipe(Layer.provide(EscalationChainsService.layer)), HttpV2IngestKeysLive, HttpV2SourcesLive, HttpV2ErrorIssuesLive, diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index 7502760091..18e96ea3c2 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 { HttpV2EscalationChainsLive } from "@/routes/v2/escalation-chains.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, + HttpV2EscalationChainsLive, HttpV2IngestKeysLive, HttpV2SourcesLive, HttpV2ChatIntegrationsLive, diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index 774172532c..2a9ca26711 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -11,6 +11,7 @@ import { AlertDestinationsService } from "@maple/backend/services/alerts/AlertDe import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" +import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" import { AnomalyDetectionService } from "@maple/backend/services/alerts/AnomalyDetectionService" import { PlanetScaleOAuthService } from "@maple/backend/services/auth/PlanetScaleOAuthService" @@ -179,6 +180,7 @@ export const HttpServicesLive = Layer.mergeAll( AlertIncidentAckService.layer, AlertRulesService.layer, AlertsService.layer, + EscalationChainsService.layer, AnomalyDetectionService.layer, AiTriageService.layer, InvestigationService.layer, diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index 6b53854b63..95e04f3929 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -638,6 +638,7 @@ "destination_ids": [ "dest_oybbpTBhtSFGShMjjLiCrh" ], + "escalation_chain_id": null, "recovery_value": null, "severity": "critical", "upper": null, @@ -1293,6 +1294,7 @@ "destination_ids": [ "dest_oybbpTBhtSFGShMjjLiCrh" ], + "escalation_chain_id": null, "recovery_value": null, "severity": "critical", "upper": null, @@ -1417,6 +1419,10 @@ }, "type": "array" }, + "escalation_chain_id": { + "$ref": "#/components/schemas/_maple_EscalationChainId", + "description": "Escalation chain (`esc_…`) an incident at this severity starts, on top of `destination_ids`, or `null` (the default) for none. It pages step by step until the incident is acknowledged or resolved." + }, "recovery_value": { "description": "The value the signal must cross back over before this tier clears, or `null` (the default) to clear as soon as it stops breaching. Must sit on the healthy side of `value`.", "examples": [ @@ -7166,6 +7172,15 @@ "title": "Public ID", "type": "string" }, + "_maple_EscalationChainId": { + "description": "Opaque, prefixed public object ID (e.g. `esc_YofPTrK9782DWwcnXhpcCw`). A reversible base58 encoding of the internal ID — treat it as an opaque string.", + "examples": [ + "esc_YofPTrK9782DWwcnXhpcCw" + ], + "format": "maple.public_id", + "title": "Public ID", + "type": "string" + }, "_maple_IssueKind": { "enum": [ "error", @@ -14053,6 +14068,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. An open incident can be acknowledged to pause its reminders.", "name": "Alert Incidents" }, + { + "description": "Step-by-step paging for alert incidents: notify the first responder, wait for an acknowledgement, then widen the circle. A rule's severity tier names the chain it starts.", + "name": "Escalation Chains" + }, { "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" diff --git a/apps/landing/src/content/docs/reference/mcp.md b/apps/landing/src/content/docs/reference/mcp.md index 82a9aba088..34a6d3f378 100644 --- a/apps/landing/src/content/docs/reference/mcp.md +++ b/apps/landing/src/content/docs/reference/mcp.md @@ -114,6 +114,10 @@ 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_escalation_chains` | read | Escalation chains with their steps, repeats and the rules using them | +| `create_escalation_chain` | write | Page destinations step by step, waiting for an acknowledgement between steps | +| `update_escalation_chain` | write | Change a chain's name, steps, repeats or description | +| `delete_escalation_chain` | write | Delete a chain no rule uses | ### Dashboards diff --git a/apps/web/e2e/tests/alert-rules.e2e.ts b/apps/web/e2e/tests/alert-rules.e2e.ts index df8807f0ef..feeef87386 100644 --- a/apps/web/e2e/tests/alert-rules.e2e.ts +++ b/apps/web/e2e/tests/alert-rules.e2e.ts @@ -11,6 +11,7 @@ interface Tier { upper: number | null recovery_value: number | null destination_ids: string[] + escalation_chain_id: string | null } interface Rule { id: string @@ -51,6 +52,7 @@ test.describe("alert rules", () => { upper: null, recovery_value: 0.005, destination_ids: [warn.body.id], + escalation_chain_id: null, }, { severity: "critical", @@ -58,6 +60,7 @@ test.describe("alert rules", () => { upper: null, recovery_value: null, destination_ids: [page.body.id], + escalation_chain_id: null, }, ] const created = await api(browser, appUrl, "/v2/alerts/rules", { diff --git a/apps/web/src/page/alerts/editor/form-steps.ts b/apps/web/src/page/alerts/editor/form-steps.ts index b15b2f6a87..f3c2ea3263 100644 --- a/apps/web/src/page/alerts/editor/form-steps.ts +++ b/apps/web/src/page/alerts/editor/form-steps.ts @@ -36,7 +36,12 @@ export const setSeverity = (form: RuleForm, severity: AlertSeverity): RuleForm = export const addCriticalTier = (form: RuleForm): RuleForm => ({ ...form, severity: "warning", - escalation: { threshold: suggestCriticalThreshold(form), recoveryValue: "", destinationIds: [] }, + escalation: { + threshold: suggestCriticalThreshold(form), + recoveryValue: "", + destinationIds: [], + escalationChainId: null, + }, }) /** Services: each named service is already its own group, and exclude only narrows "all services". */ diff --git a/apps/web/src/page/alerts/editor/form.ts b/apps/web/src/page/alerts/editor/form.ts index 381801e6db..489145a7d2 100644 --- a/apps/web/src/page/alerts/editor/form.ts +++ b/apps/web/src/page/alerts/editor/form.ts @@ -1,4 +1,10 @@ -import { AlertComparator, AlertDestinationId, AlertSeverity, AlertSignalType } from "@maple/domain/http" +import { + AlertComparator, + AlertDestinationId, + AlertSeverity, + AlertSignalType, + EscalationChainId, +} from "@maple/domain/http" import { QueryEngineAlertReducer } from "@maple/domain/query-engine" import type { RuleFormState } from "@maple/web-data/alerts/form-utils" import { Schema } from "effect" @@ -14,6 +20,7 @@ export const EscalationTier = Schema.Struct({ threshold: Schema.String, recoveryValue: Schema.String, destinationIds: DestinationIds, + escalationChainId: Schema.NullOr(EscalationChainId), }) export const RuleForm = Schema.Struct({ @@ -45,6 +52,7 @@ export const RuleForm = Schema.Struct({ rawQuerySql: Schema.String, rawQueryReducer: QueryEngineAlertReducer, destinationIds: DestinationIds, + escalationChainId: Schema.NullOr(EscalationChainId), notificationTitle: Schema.String, notificationBody: Schema.String, }) 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..5b893504fd 100644 --- a/apps/web/src/page/investigations/conversation/kit/tool-metadata.ts +++ b/apps/web/src/page/investigations/conversation/kit/tool-metadata.ts @@ -64,6 +64,10 @@ const TOOL_LABELS = new Map([ ["list_alert_checks", "List Alert Checks"], ["preview_alert_rule", "Preview Alert Rule"], ["get_incident_timeline", "Incident Timeline"], + ["list_escalation_chains", "List Escalation Chains"], + ["create_escalation_chain", "Create Escalation Chain"], + ["update_escalation_chain", "Update Escalation Chain"], + ["delete_escalation_chain", "Delete Escalation Chain"], ["get_error_notification_policy", "Notification Policy"], ["update_error_notification_policy", "Notification Policy"], // sessions @@ -133,6 +137,10 @@ const TOOL_ICONS = new Map([ ["list_alert_checks", "CircleCheckIcon"], ["preview_alert_rule", "HistoryIcon"], ["get_incident_timeline", "HistoryIcon"], + ["list_escalation_chains", "BellIcon"], + ["create_escalation_chain", "BellIcon"], + ["update_escalation_chain", "BellIcon"], + ["delete_escalation_chain", "TrashIcon"], ["get_error_notification_policy", "BellIcon"], ["update_error_notification_policy", "BellIcon"], ["run_sql", "DatabaseIcon"], diff --git a/packages/domain/src/audit-actions.ts b/packages/domain/src/audit-actions.ts index 0c9b5fd48c..84c1987354 100644 --- a/packages/domain/src/audit-actions.ts +++ b/packages/domain/src/audit-actions.ts @@ -67,6 +67,10 @@ export const AuditResources = { device_credential: { prefix: PublicIdPrefixes.deviceCredential, verbs: ["registered", "revoked"] }, /** Verbs mirror the issue event types: `recordEvent` audits every one it attributes. */ error_issue: { prefix: PublicIdPrefixes.errorIssue, verbs: ErrorIssueEventType.literals }, + escalation_chain: { + prefix: PublicIdPrefixes.escalationChain, + verbs: ["created", "updated", "deleted"], + }, /** One connected Google Cloud scope; the scope and what it collects are in `metadata`. */ gcp_connector: { prefix: PublicIdPrefixes.gcpConnector, @@ -187,6 +191,7 @@ export const AuditResourceLabels = { ai_provider_key: "AI provider key", alert_destination: "Alert destination", alert_rule: "Alert rule", + escalation_chain: "Escalation chain", anomaly_incident: "Anomaly", anomaly_settings: "Anomaly detection", api: "API", @@ -237,6 +242,9 @@ export const AuditActionLabels = { "alert_rule.created": "Created alert rule", "alert_rule.updated": "Updated alert rule", "alert_rule.deleted": "Deleted alert rule", + "escalation_chain.created": "Created escalation chain", + "escalation_chain.updated": "Updated escalation chain", + "escalation_chain.deleted": "Deleted escalation chain", "anomaly_incident.resolved": "Resolved anomaly", "anomaly_settings.updated": "Updated anomaly detection", "api.request": "Made API request", diff --git a/packages/domain/src/generated/anticipated-error-identifiers.ts b/packages/domain/src/generated/anticipated-error-identifiers.ts index 441ce1dac5..d85a83750b 100644 --- a/packages/domain/src/generated/anticipated-error-identifiers.ts +++ b/packages/domain/src/generated/anticipated-error-identifiers.ts @@ -65,6 +65,8 @@ export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray = [ "@maple/http/errors/ErrorIssueTransitionError", "@maple/http/errors/ErrorNotificationUnknownDestinationError", "@maple/http/errors/ErrorValidationError", + "@maple/http/errors/EscalationChainInUseError", + "@maple/http/errors/EscalationChainNotFoundError", "@maple/http/errors/GcpMetricsUnavailableError", "@maple/http/errors/GcpScopeAlreadyConnectedError", "@maple/http/errors/IncidentTriageUnauthorizedError", diff --git a/packages/domain/src/http/v2/alert-rules.ts b/packages/domain/src/http/v2/alert-rules.ts index a475185bb8..8b0650879f 100644 --- a/packages/domain/src/http/v2/alert-rules.ts +++ b/packages/domain/src/http/v2/alert-rules.ts @@ -38,7 +38,7 @@ import { wireExample, ListOf, ListQuery, Timestamp } from "./envelopes" import { V2ParameterInvalid } from "./errors" import { publicError, publicErrors } from "./public-error" import { V2ManagedWarehouseErrors, V2QueryEngineRouteErrors, V2WarehouseErrors } from "./query-errors" -import { AlertIncidentPublicId, AlertRulePublicId } from "./resource-ids" +import { AlertIncidentPublicId, AlertRulePublicId, EscalationChainPublicId } from "./resource-ids" export { AlertIncidentPublicId, AlertRulePublicId } from "./resource-ids" @@ -73,6 +73,12 @@ export const V2AlertThreshold = Schema.Struct({ description: "Destinations (`dest_…`) this severity notifies. A `critical` incident also notifies every `warning` destination.", }), + escalation_chain_id: Schema.NullOr(EscalationChainPublicId) + .annotate({ + description: + "Escalation chain (`esc_…`) an incident at this severity starts, on top of `destination_ids`, or `null` (the default) for none. It pages step by step until the incident is acknowledged or resolved.", + }) + .pipe(Schema.withDecodingDefaultKey(Effect.succeed(null))), }).annotate({ identifier: "AlertThreshold", title: "Alert threshold", @@ -93,6 +99,7 @@ export const fromV2Threshold = (tier: V2AlertThreshold): AlertThreshold => ({ upper: tier.upper, recoveryValue: tier.recovery_value, destinationIds: tier.destination_ids, + escalationChainId: tier.escalation_chain_id, }) export const toV2Threshold = (tier: AlertThreshold): V2AlertThreshold => ({ @@ -101,6 +108,7 @@ export const toV2Threshold = (tier: AlertThreshold): V2AlertThreshold => ({ upper: tier.upper, recovery_value: tier.recoveryValue, destination_ids: tier.destinationIds, + escalation_chain_id: tier.escalationChainId ?? null, }) /** Mirrors the v1 caps (alerts.ts): 32-char tags, at most 20 per rule. */ @@ -143,6 +151,7 @@ const alertRuleExample = { upper: null, recovery_value: null, destination_ids: ["dest_oybbpTBhtSFGShMjjLiCrh"], + escalation_chain_id: null, }, ], window_minutes: 5, diff --git a/packages/domain/src/http/v2/api.ts b/packages/domain/src/http/v2/api.ts index 6ab2a987bb..8937f90b27 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 { V2EscalationChainsApiGroup } from "./escalation-chains" 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(V2EscalationChainsApiGroup) .add(V2IngestKeysApiGroup) .add(V2SourcesApiGroup) .add(V2ChatIntegrationsApiGroup) diff --git a/packages/domain/src/http/v2/escalation-chains.ts b/packages/domain/src/http/v2/escalation-chains.ts new file mode 100644 index 0000000000..786633b7d9 --- /dev/null +++ b/packages/domain/src/http/v2/escalation-chains.ts @@ -0,0 +1,264 @@ +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api" +import { Array as Arr, Schema } from "effect" +import { AlertForbiddenError, AlertPersistenceError, AlertValidationError } from "../alerts" +import { + EscalationChainDescription, + EscalationChainInUseError, + EscalationChainName, + EscalationChainNotFoundError, + EscalationRepeatCount, + EscalationWaitMinutes, + MAX_ESCALATION_REPEATS, + MAX_ESCALATION_STEPS, + MAX_ESCALATION_STEP_TARGETS, + MAX_ESCALATION_WAIT_MINUTES, +} from "../escalation-chains" +import { AuthorizationV2 } from "./auth" +import { wireExample, ListOf, ListQuery, Timestamp } from "./envelopes" +import { V2ParameterInvalid } from "./errors" +import { publicErrors } from "./public-error" +import { AlertDestinationPublicId, AlertRulePublicId, EscalationChainPublicId } from "./resource-ids" + +export { EscalationChainPublicId } from "./resource-ids" + +export const V2EscalationTarget = Schema.Struct({ + type: Schema.Literal("destination").annotate({ + description: 'What the step notifies. Always `"destination"` for now.', + }), + destination_id: AlertDestinationPublicId.annotate({ + description: "The alert destination (`dest_…`) this step notifies.", + }), +}).annotate({ + identifier: "EscalationTarget", + title: "Escalation target", + description: "Who one escalation step notifies.", +}) +export type V2EscalationTarget = Schema.Schema.Type + +// Bounded arrays on the wire, not tuples: generated clients do not all read `prefixItems`. +// The refine narrows the decoded type to non-empty and goes first, so the checks carry the docs. +const V2EscalationTargets = Schema.Array(V2EscalationTarget) + .pipe(Schema.refine(Arr.isReadonlyArrayNonEmpty)) + .check(Schema.isMinLength(1), Schema.isMaxLength(MAX_ESCALATION_STEP_TARGETS)) + +export const V2EscalationStep = Schema.Struct({ + targets: V2EscalationTargets.annotate({ + description: `Who this step notifies, all at once: 1 to ${MAX_ESCALATION_STEP_TARGETS} targets.`, + }), + wait_minutes: EscalationWaitMinutes.annotate({ + description: `Minutes to wait for an acknowledgement before the next step (1 to ${MAX_ESCALATION_WAIT_MINUTES}).`, + examples: [10], + }), +}).annotate({ + identifier: "EscalationStep", + title: "Escalation step", + description: "Notify every target, then wait `wait_minutes` for an acknowledgement before the next step.", +}) +export type V2EscalationStep = Schema.Schema.Type + +const V2EscalationSteps = Schema.Array(V2EscalationStep) + .pipe(Schema.refine(Arr.isReadonlyArrayNonEmpty)) + .check(Schema.isMinLength(1), Schema.isMaxLength(MAX_ESCALATION_STEPS)) + .annotate({ description: `The steps in order, 1 to ${MAX_ESCALATION_STEPS}.` }) + +const RepeatCount = EscalationRepeatCount.annotate({ + description: `Extra passes over the whole chain after the first, 0 to ${MAX_ESCALATION_REPEATS}. \`0\` runs it once.`, + examples: [1], +}) + +const escalationChainExample = { + id: "esc_gQtGH2s65G6a7sJmuayXL6", + object: "escalation_chain", + name: "Checkout on-call", + description: "Page the primary, then the secondary, then the team lead.", + steps: [ + { + targets: [{ type: "destination", destination_id: "dest_aMadCYFsFjn8nerC3aEWyG" }], + wait_minutes: 10, + }, + { + targets: [{ type: "destination", destination_id: "dest_cTp8s4DaBKsNmcma6yJGSn" }], + wait_minutes: 15, + }, + ], + repeat_count: 1, + rule_ids: ["alrt_pJsL1o13V4GRjetnBTaWLv"], + created_at: "2026-07-15T09:00:00.000Z", + updated_at: "2026-07-15T09:00:00.000Z", +} as const + +export const V2EscalationChain = Schema.Struct({ + id: EscalationChainPublicId, + object: Schema.Literal("escalation_chain").annotate({ + description: 'The object type, always `"escalation_chain"`.', + examples: ["escalation_chain"], + }), + name: Schema.String.annotate({ description: "Display name.", examples: ["Checkout on-call"] }), + description: Schema.NullOr(Schema.String).annotate({ description: "Free-form notes, or `null`." }), + steps: V2EscalationSteps, + repeat_count: RepeatCount, + rule_ids: Schema.Array(AlertRulePublicId).annotate({ + description: + "Alert rules (`alrt_…`) with a severity tier that starts this chain. A chain in use cannot be deleted.", + }), + created_at: Timestamp, + updated_at: Timestamp, +}).annotate({ + identifier: "EscalationChain", + title: "Escalation Chain", + description: + "An ordered paging policy. When an incident opens at a severity tier that names the chain, the tier's own destinations fire at once and the chain runs on top: each step notifies its targets, then waits for an acknowledgement before the next. Escalation stops when the incident is acknowledged or resolved.", + examples: [wireExample(escalationChainExample)], +}) +export type V2EscalationChain = Schema.Schema.Type + +export const V2EscalationChainCreateParams = Schema.Struct({ + name: EscalationChainName.annotate({ description: "Display name, 1 to 120 characters." }), + description: Schema.optionalKey( + Schema.NullOr(EscalationChainDescription).annotate({ + description: "Free-form notes, at most 500 characters.", + }), + ), + steps: V2EscalationSteps, + repeat_count: Schema.optionalKey(RepeatCount), +}).annotate({ + identifier: "EscalationChainCreateParams", + title: "Create escalation chain", + description: "Every destination a step names must exist in your organization.", + examples: [ + wireExample({ + name: escalationChainExample.name, + steps: escalationChainExample.steps, + repeat_count: 1, + }), + ], +}) +export type V2EscalationChainCreateParams = Schema.Schema.Type + +export const V2EscalationChainUpdateParams = Schema.Struct({ + name: Schema.optionalKey(EscalationChainName), + description: Schema.optionalKey(Schema.NullOr(EscalationChainDescription)), + steps: Schema.optionalKey( + V2EscalationSteps.annotate({ + description: "Replaces every step. Running escalations keep their own copy.", + }), + ), + repeat_count: Schema.optionalKey(RepeatCount), +}).annotate({ + identifier: "EscalationChainUpdateParams", + title: "Update escalation chain", + description: + "Omitted fields are unchanged. An escalation already running finishes on the chain it started with.", + examples: [wireExample({ repeat_count: 2 })], +}) +export type V2EscalationChainUpdateParams = Schema.Schema.Type + +export const V2EscalationChainDeleteResponse = Schema.Struct({ + id: EscalationChainPublicId, + object: Schema.Literal("escalation_chain").annotate({ + description: 'The object type, always `"escalation_chain"`.', + }), + deleted: Schema.Literal(true).annotate({ description: "Always `true`: the chain no longer exists." }), +}).annotate({ + identifier: "EscalationChainDeleteResponse", + title: "Escalation chain delete response", + description: "Confirmation that an escalation chain was deleted.", + examples: [wireExample({ id: escalationChainExample.id, object: "escalation_chain", deleted: true })], +}) +export type V2EscalationChainDeleteResponse = Schema.Schema.Type + +const EscalationChainList = ListOf(V2EscalationChain).annotate({ + identifier: "EscalationChainList", + title: "Escalation chain list", + description: "A cursor-paginated page of escalation chains, by name.", +}) + +const [alertForbidden, alertValidation, alertPersistence, chainNotFound, chainInUse] = publicErrors( + AlertForbiddenError, + AlertValidationError, + AlertPersistenceError, + EscalationChainNotFoundError, + EscalationChainInUseError, +) + +export class V2EscalationChainsApiGroup extends HttpApiGroup.make("escalationChains") + .add( + HttpApiEndpoint.get("list", "/", { + query: ListQuery, + success: EscalationChainList, + error: [V2ParameterInvalid.schema, alertPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "listEscalationChains", + summary: "List escalation chains", + description: + "Returns your organization's escalation chains by name, each with the rules that use it. Requires the `alerts:read` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.get("retrieve", "/:id", { + params: { id: EscalationChainPublicId }, + success: V2EscalationChain, + error: [chainNotFound, alertPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "getEscalationChain", + summary: "Retrieve an escalation chain", + description: + "Returns a single escalation chain by its `esc_…` ID. Requires the `alerts:read` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.post("create", "/", { + payload: V2EscalationChainCreateParams, + success: V2EscalationChain, + error: [alertForbidden, alertValidation, alertPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "createEscalationChain", + summary: "Create an escalation chain", + description: + "Creates a chain. Attach it to an alert rule through a severity tier's `escalation_chain_id`. Fails with an `invalid_request_error` when a step names an unknown destination. Requires a role that can manage alerts and the `alerts:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.patch("update", "/:id", { + params: { id: EscalationChainPublicId }, + payload: V2EscalationChainUpdateParams, + success: V2EscalationChain, + error: [alertForbidden, alertValidation, alertPersistence, chainNotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "updateEscalationChain", + summary: "Update an escalation chain", + description: + "Changes the name, description, steps or repeat count. New incidents use the new steps. Requires the `alerts:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("delete", "/:id", { + params: { id: EscalationChainPublicId }, + success: V2EscalationChainDeleteResponse, + error: [alertForbidden, alertPersistence, chainNotFound, chainInUse], + }).annotateMerge( + OpenApi.annotations({ + identifier: "deleteEscalationChain", + summary: "Delete an escalation chain", + description: + "Deletes a chain. Fails with a `conflict_error` while any alert rule tier still names it; detach it from those rules first. Requires the `alerts:write` scope.", + }), + ), + ) + .prefix("/v2/alerts/escalation_chains") + .middleware(AuthorizationV2) + .annotateMerge( + OpenApi.annotations({ + title: "Escalation Chains", + description: + "Step-by-step paging for alert incidents: notify the first responder, wait for an acknowledgement, then widen the circle. A rule's severity tier names the chain it starts.", + }), + ) {} diff --git a/packages/domain/src/http/v2/index.ts b/packages/domain/src/http/v2/index.ts index 574394f678..6ff4aff3f9 100644 --- a/packages/domain/src/http/v2/index.ts +++ b/packages/domain/src/http/v2/index.ts @@ -13,6 +13,7 @@ export * from "./dashboards" export * from "./envelopes" export * from "./errors" export * from "./error-issues" +export * from "./escalation-chains" export * from "./ingest-keys" export * from "./sources" export * from "./integrations-chat" diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index ff2669f0ae..87da9e8601 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -81,6 +81,7 @@ describe("MapleApiV2 OpenAPI", () => { expect(surface).toEqual([ "DELETE /v2/alerts/destinations/{id}", + "DELETE /v2/alerts/escalation_chains/{id}", "DELETE /v2/alerts/rules/{id}", "DELETE /v2/api_keys/{id}", "DELETE /v2/attribute_mappings/{id}", @@ -109,6 +110,8 @@ describe("MapleApiV2 OpenAPI", () => { "GET /v2/alerts/deliveries", "GET /v2/alerts/destinations", "GET /v2/alerts/destinations/{id}", + "GET /v2/alerts/escalation_chains", + "GET /v2/alerts/escalation_chains/{id}", "GET /v2/alerts/incidents", "GET /v2/alerts/incidents/{id}", "GET /v2/alerts/incidents/{id}/events", @@ -196,6 +199,7 @@ describe("MapleApiV2 OpenAPI", () => { "GET /v2/users/{user_id}/issues", "GET /v2/widget_summary", "PATCH /v2/alerts/destinations/{id}", + "PATCH /v2/alerts/escalation_chains/{id}", "PATCH /v2/alerts/rules/{id}", "PATCH /v2/anomalies/settings", "PATCH /v2/attribute_mappings/{id}", @@ -214,6 +218,7 @@ describe("MapleApiV2 OpenAPI", () => { "POST /v2/alerts/destinations", "POST /v2/alerts/destinations/telegram/chats", "POST /v2/alerts/destinations/{id}/test", + "POST /v2/alerts/escalation_chains", "POST /v2/alerts/incidents/{id}/acknowledge", "POST /v2/alerts/incidents/{id}/unacknowledge", "POST /v2/alerts/rules", diff --git a/packages/domain/src/http/v2/public-id.ts b/packages/domain/src/http/v2/public-id.ts index 086531fd75..e747c160cf 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", + escalationChain: "esc", actor: "actor", auditLogEntry: "alog", errorIssue: "iss", diff --git a/packages/domain/src/http/v2/resource-ids.ts b/packages/domain/src/http/v2/resource-ids.ts index ef5aa99576..df01f47218 100644 --- a/packages/domain/src/http/v2/resource-ids.ts +++ b/packages/domain/src/http/v2/resource-ids.ts @@ -10,6 +10,7 @@ import { DeviceCredentialId, ErrorIncidentId, ErrorIssueId, + EscalationChainId, InvestigationId, McpConnectionId, MobileDeviceId, @@ -22,6 +23,7 @@ export const AlertDestinationPublicId = PublicId(PublicIdPrefixes.alertDestinati export const AlertIncidentPublicId = PublicId(PublicIdPrefixes.alertIncident, AlertIncidentId) export const AlertDeliveryEventPublicId = PublicId(PublicIdPrefixes.event, AlertDeliveryEventId) export const AlertIncidentEventPublicId = PublicId(PublicIdPrefixes.event, AlertIncidentEventId) +export const EscalationChainPublicId = PublicId(PublicIdPrefixes.escalationChain, EscalationChainId) export const ActorPublicId = PublicId(PublicIdPrefixes.actor, ActorId) export const AnomalyIncidentPublicId = PublicId(PublicIdPrefixes.anomalyIncident, AnomalyIncidentId) export const ErrorIncidentPublicId = PublicId(PublicIdPrefixes.errorIncident, ErrorIncidentId) diff --git a/packages/domain/src/http/v2/v2-contract.test.ts b/packages/domain/src/http/v2/v2-contract.test.ts index c7c4866226..0d97ae339e 100644 --- a/packages/domain/src/http/v2/v2-contract.test.ts +++ b/packages/domain/src/http/v2/v2-contract.test.ts @@ -7,6 +7,7 @@ import { V2AlertRule, V2AlertRuleMutationResponse } from "./alert-rules" import { V2ApiKey, V2ApiKeyMutationResponse, V2ApiKeyWithSecret } from "./api-keys" import { V2DashboardMutation } from "./dashboards" import { V2ErrorIssue } from "./error-issues" +import { V2EscalationChain } from "./escalation-chains" import { AuthorizationV2, isScopeExemptRoute, requiredScopeForRoute, scopeAllows, V2Scope } from "./auth" import { V2PlanetScaleIntegration, @@ -419,6 +420,60 @@ describe("V2 alerts wire format", () => { expect("txid" in Schema.encodeSync(V2AlertRule)(base)).toBe(false) }) + it("reads an omitted tier escalation chain as null and round-trips an esc_ id", () => { + const CHAIN_UUID = "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b" + const without = Schema.decodeUnknownSync(V2AlertRule)(ruleWire) + expect(without.thresholds[0]?.escalation_chain_id).toBeNull() + + const [tier] = ruleWire.thresholds + const withChain = Schema.decodeUnknownSync(V2AlertRule)({ + ...ruleWire, + thresholds: [{ ...tier, escalation_chain_id: encodePublicId("esc", CHAIN_UUID) }], + }) + expect(withChain.thresholds[0]?.escalation_chain_id).toBe(CHAIN_UUID) + expect(Schema.encodeSync(V2AlertRule)(withChain).thresholds[0]?.escalation_chain_id).toBe( + encodePublicId("esc", CHAIN_UUID), + ) + expect(() => + Schema.decodeUnknownSync(V2AlertRule)({ + ...ruleWire, + thresholds: [{ ...tier, escalation_chain_id: encodePublicId("dest", CHAIN_UUID) }], + }), + ).toThrow() + }) + + it("encodes an escalation chain with esc_/dest_/alrt_ ids and snake_case steps", () => { + const CHAIN_UUID = "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b" + const chainWire = { + id: encodePublicId("esc", CHAIN_UUID), + object: "escalation_chain", + name: "On-call", + description: null, + steps: [ + { + targets: [{ type: "destination", destination_id: encodePublicId("dest", DEST_UUID) }], + wait_minutes: 10, + }, + ], + repeat_count: 1, + rule_ids: [encodePublicId("alrt", UUID)], + created_at: "2026-07-15T00:00:00.000Z", + updated_at: "2026-07-15T00:00:00.000Z", + } + const chain = Schema.decodeUnknownSync(V2EscalationChain)(chainWire) + expect(chain.id).toBe(CHAIN_UUID) + expect(chain.steps[0]?.targets[0]?.destination_id).toBe(DEST_UUID) + expect(chain.rule_ids).toEqual([UUID]) + expect(Schema.encodeSync(V2EscalationChain)(chain)).toEqual(chainWire) + expect(() => Schema.decodeUnknownSync(V2EscalationChain)({ ...chainWire, steps: [] })).toThrow() + expect(() => + Schema.decodeUnknownSync(V2EscalationChain)({ + ...chainWire, + steps: [{ ...chainWire.steps[0], wait_minutes: 0 }], + }), + ).toThrow() + }) + it("rejects wrong-prefix rule IDs", () => { expect(() => Schema.decodeUnknownSync(V2AlertRule)({ ...ruleWire, id: encodePublicId("dash", UUID) }), diff --git a/packages/domain/src/mcp-outputs/alerts.ts b/packages/domain/src/mcp-outputs/alerts.ts index 17e5776ed7..3504f80d48 100644 --- a/packages/domain/src/mcp-outputs/alerts.ts +++ b/packages/domain/src/mcp-outputs/alerts.ts @@ -23,6 +23,16 @@ export const AlertRuleRow = Schema.Struct({ /** Set when the scheduler slowed the rule after repeated query timeouts; cleared by an edit. */ slowedIntervalMinutes: Schema.NullOr(Schema.Number), destinationIds: Schema.Array(Schema.String), + /** Escalation chains the rule's tiers start, when any do; `name` only where it was looked up. */ + escalationChains: Schema.optionalKey( + Schema.Array( + Schema.Struct({ + severity: Schema.String, + chainId: Schema.String, + name: Schema.optionalKey(NullableString), + }), + ), + ), createdAt: Schema.String, updatedAt: Schema.String, }) diff --git a/packages/domain/src/mcp-outputs/catalog.ts b/packages/domain/src/mcp-outputs/catalog.ts index e279057cef..11d0e3aa40 100644 --- a/packages/domain/src/mcp-outputs/catalog.ts +++ b/packages/domain/src/mcp-outputs/catalog.ts @@ -46,6 +46,11 @@ import { ListAlertRulesOutput, UpdateAlertRuleOutput, } from "./alerts" +import { + DeleteEscalationChainOutput, + EscalationChainMutationOutput, + ListEscalationChainsOutput, +} from "./escalation-chains" import { AddDashboardWidgetOutput, CreateDashboardOutput, @@ -140,6 +145,7 @@ export const McpToolOutputs = { compare_commits: CompareCommitsOutput, compare_periods: ComparePeriodsOutput, create_alert_rule: CreateAlertRuleOutput, + create_escalation_chain: EscalationChainMutationOutput, create_ingest_rule: IngestRuleMutationOutput, create_scrape_target: ScrapeTargetMutationOutput, create_dashboard: CreateDashboardOutput, @@ -149,6 +155,7 @@ export const McpToolOutputs = { delete_ingest_rule: DeleteIngestRuleOutput, delete_scrape_target: DeleteScrapeTargetOutput, delete_alert_rule: DeleteAlertRuleOutput, + delete_escalation_chain: DeleteEscalationChainOutput, acknowledge_alert_incident: AcknowledgeAlertIncidentOutput, delete_uptime_monitor: DeleteUptimeMonitorOutput, describe_dashboard_schema: DescribeDashboardSchemaOutput, @@ -186,6 +193,7 @@ export const McpToolOutputs = { list_alert_destinations: ListAlertDestinationsOutput, list_alert_incidents: ListAlertIncidentsOutput, list_alert_rules: ListAlertRulesOutput, + list_escalation_chains: ListEscalationChainsOutput, list_dashboards: ListDashboardsOutput, list_error_incidents: ListErrorIncidentsOutput, list_error_issue_events: ListErrorIssueEventsOutput, @@ -241,6 +249,7 @@ export const McpToolOutputs = { transition_error_issue: TransitionErrorIssueOutput, transition_error_issues: TransitionErrorIssuesOutput, update_alert_rule: UpdateAlertRuleOutput, + update_escalation_chain: EscalationChainMutationOutput, update_dashboard: UpdateDashboardOutput, update_dashboard_widget: UpdateDashboardWidgetOutput, update_ingest_rule: IngestRuleMutationOutput, diff --git a/packages/domain/src/mcp-outputs/escalation-chains.ts b/packages/domain/src/mcp-outputs/escalation-chains.ts new file mode 100644 index 0000000000..f36193ef1f --- /dev/null +++ b/packages/domain/src/mcp-outputs/escalation-chains.ts @@ -0,0 +1,39 @@ +/** Output schemas for the escalation chain MCP tools. */ +import { Schema } from "effect" + +const NullableString = Schema.NullOr(Schema.String) + +export const EscalationTargetRow = Schema.Struct({ + destinationId: Schema.String, + /** Null when the destination list could not be read or the destination is gone. */ + name: NullableString, + type: NullableString, +}) + +export const EscalationStepRow = Schema.Struct({ + targets: Schema.Array(EscalationTargetRow), + waitMinutes: Schema.Number, +}) + +export const EscalationChainRow = Schema.Struct({ + id: Schema.String, + /** The same chain's id in the REST API (`esc_…`). */ + publicId: Schema.String, + name: Schema.String, + description: NullableString, + steps: Schema.Array(EscalationStepRow), + repeatCount: Schema.Number, + /** Rules with a tier that starts this chain. */ + ruleIds: Schema.Array(Schema.String), + createdAt: Schema.String, + updatedAt: Schema.String, +}) + +export const ListEscalationChainsOutput = Schema.Struct({ + chains: Schema.Array(EscalationChainRow), + total: Schema.Number, +}) + +export const EscalationChainMutationOutput = Schema.Struct({ chain: EscalationChainRow }) + +export const DeleteEscalationChainOutput = Schema.Struct({ id: Schema.String, name: Schema.String }) diff --git a/packages/domain/src/mcp-outputs/index.ts b/packages/domain/src/mcp-outputs/index.ts index 121daed48f..f799fdd77c 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 "./escalation-chains" export * from "./ingest-rules" export * from "./dashboards" export * from "./issues" diff --git a/packages/web-data/src/alerts/form-utils.test.ts b/packages/web-data/src/alerts/form-utils.test.ts index 63155df403..c3d20698ac 100644 --- a/packages/web-data/src/alerts/form-utils.test.ts +++ b/packages/web-data/src/alerts/form-utils.test.ts @@ -302,7 +302,14 @@ describe("v2 response mappers", () => { threshold: 0.05, threshold_upper: null, thresholds: [ - { severity: "warning", value: 0.05, upper: null, recovery_value: 0.03, destination_ids: [] }, + { + severity: "warning", + value: 0.05, + upper: null, + recovery_value: 0.03, + destination_ids: [], + escalation_chain_id: null, + }, ], comparator: "gt", truncated_to_start: "2026-07-15T00:00:00.000Z", @@ -331,7 +338,14 @@ describe("v2 response mappers", () => { }) expect(response.thresholds).toEqual([ - { severity: "warning", value: 0.05, upper: null, recoveryValue: 0.03, destinationIds: [] }, + { + severity: "warning", + value: 0.05, + upper: null, + recoveryValue: 0.03, + destinationIds: [], + escalationChainId: null, + }, ]) expect(response.bucketSeconds).toBe(300) expect(response.windowMinutes).toBe(5) @@ -361,7 +375,14 @@ describe("v2 response mappers", () => { threshold: 10, threshold_upper: null, thresholds: [ - { severity: "warning", value: 10, upper: null, recovery_value: null, destination_ids: [] }, + { + severity: "warning", + value: 10, + upper: null, + recovery_value: null, + destination_ids: [], + escalation_chain_id: null, + }, ], comparator: "gt", truncated_to_start: null, diff --git a/packages/web-data/src/alerts/form-utils.ts b/packages/web-data/src/alerts/form-utils.ts index f87216b077..c9541f927f 100644 --- a/packages/web-data/src/alerts/form-utils.ts +++ b/packages/web-data/src/alerts/form-utils.ts @@ -16,6 +16,7 @@ import { type AlertComparator, type AlertDeliveryStatus, type AlertDestinationId, + type EscalationChainId, type AlertDestinationType, type AlertEventType, type AlertSeverity, @@ -105,6 +106,8 @@ export type RuleFormState = { rawQuerySql: string rawQueryReducer: QueryEngineAlertReducer destinationIds: AlertDestinationId[] + /** The escalation chain the main tier starts; null when it only notifies its destinations. */ + escalationChainId: EscalationChainId | null /** * Custom notification message. Empty strings mean "use the built-in format". * `title` + Markdown `body` support `{{ variable }}` substitution; channels @@ -119,6 +122,7 @@ export interface EscalationTierForm { threshold: string recoveryValue: string destinationIds: AlertDestinationId[] + escalationChainId: EscalationChainId | null } export const signalLabels: Record = { @@ -327,6 +331,7 @@ export function defaultRuleForm(serviceName?: string): RuleFormState { rawQuerySql: DEFAULT_RAW_QUERY_SQL, rawQueryReducer: "identity", destinationIds: [], + escalationChainId: null, notificationTitle: "", notificationBody: "", } @@ -369,6 +374,7 @@ export function ruleToFormState(rule: AlertRuleDocument): RuleFormState { threshold: toForm(escalation.value), recoveryValue: toForm(escalation.recoveryValue), destinationIds: [...escalation.destinationIds], + escalationChainId: escalation.escalationChainId ?? null, }, windowMinutes: String(rule.windowMinutes), evaluationIntervalMinutes: @@ -384,6 +390,7 @@ export function ruleToFormState(rule: AlertRuleDocument): RuleFormState { rawQuerySql: rule.rawQuerySql ?? DEFAULT_RAW_QUERY_SQL, rawQueryReducer: rule.rawQueryReducer ?? "identity", destinationIds: [...primary.destinationIds], + escalationChainId: primary.escalationChainId ?? null, notificationTitle: rule.notificationTemplate?.title ?? "", notificationBody: rule.notificationTemplate?.body ?? "", } @@ -503,6 +510,7 @@ export function formThresholds(form: RuleFormState): Arr.NonEmptyReadonlyArray Date: Fri, 6 Nov 2026 12:40:24 +0100 Subject: [PATCH 13/98] feat(alerts): pure on-call schedule resolver Domain schemas for on-call schedules (layers, rotations, restrictions, overrides) and a pure resolver: onCallAt and renderOnCall with DST-aware local handoffs. --- packages/alerting-core/src/index.ts | 1 + packages/alerting-core/src/oncall.test.ts | 366 +++++++++++++++++++ packages/alerting-core/src/oncall.ts | 322 ++++++++++++++++ packages/domain/src/http/index.ts | 1 + packages/domain/src/http/oncall-schedules.ts | 87 +++++ 5 files changed, 777 insertions(+) create mode 100644 packages/alerting-core/src/oncall.test.ts create mode 100644 packages/alerting-core/src/oncall.ts create mode 100644 packages/domain/src/http/oncall-schedules.ts diff --git a/packages/alerting-core/src/index.ts b/packages/alerting-core/src/index.ts index 767c12bff8..20f6c58a46 100644 --- a/packages/alerting-core/src/index.ts +++ b/packages/alerting-core/src/index.ts @@ -23,6 +23,7 @@ import type { export type { AlertComparator, AlertEventType, AlertEvaluationStatus } from "@maple/domain/http" export * from "./tiers" export * from "./escalation" +export * from "./oncall" export interface AlertObservation { readonly value: number | null diff --git a/packages/alerting-core/src/oncall.test.ts b/packages/alerting-core/src/oncall.test.ts new file mode 100644 index 0000000000..06bca53fcc --- /dev/null +++ b/packages/alerting-core/src/oncall.test.ts @@ -0,0 +1,366 @@ +import { Array as Arr, Option, Schema } from "effect" +import { describe, expect, it } from "vitest" +import { OnCallOverride, OnCallScheduleConfig } from "@maple/domain/http" +import { ONCALL_LOOKAROUND_MS, onCallAt, renderOnCall, type OnCallShift } from "./oncall" + +const HOUR = 3_600_000 +const at = (iso: string) => Date.parse(iso) +const decodeConfig = Schema.decodeUnknownSync(OnCallScheduleConfig) +const decodeOverrides = Schema.decodeUnknownSync(Schema.Array(OnCallOverride)) + +// Monday 2026-03-02 09:00 Europe/Berlin (CET). +const WEEKLY_ANCHOR = at("2026-03-02T08:00:00Z") + +const weeklyLayer = { + name: "Primary", + participants: ["ana", "ben", "chen"], + rotation: { kind: "weekly", handoffWeekday: "mon", handoffTime: "09:00" }, + startsAt: WEEKLY_ANCHOR, +} +const weekly = decodeConfig({ timeZone: "Europe/Berlin", layers: [weeklyLayer] }) + +const who = (shift: OnCallShift | null) => (shift === null ? null : shift.userId) +const sourceKey = (shift: OnCallShift) => + shift.source.kind === "override" + ? `override:${shift.source.overrideId}` + : `layer:${shift.source.layerIndex}` +const span = (shift: OnCallShift | null) => + shift === null + ? null + : { start: new Date(shift.startMs).toISOString(), end: new Date(shift.endMs).toISOString() } + +describe("weekly rotation", () => { + it("hands off every Monday at 09:00 local in rotation order", () => { + expect(who(onCallAt(weekly, [], at("2026-03-02T08:00:00Z")))).toBe("ana") + expect(who(onCallAt(weekly, [], at("2026-03-09T07:59:00Z")))).toBe("ana") + expect(who(onCallAt(weekly, [], at("2026-03-09T08:00:00Z")))).toBe("ben") + expect(who(onCallAt(weekly, [], at("2026-03-18T12:00:00Z")))).toBe("chen") + expect(who(onCallAt(weekly, [], at("2026-03-25T12:00:00Z")))).toBe("ana") + }) + + it("is empty before the layer starts", () => { + expect(onCallAt(weekly, [], WEEKLY_ANCHOR - 1)).toBeNull() + }) + + it("keeps the 09:00 handoff across spring forward, giving a 167h shift", () => { + const shift = onCallAt(weekly, [], at("2026-03-29T12:00:00Z")) + expect(who(shift)).toBe("ana") + expect(span(shift)).toEqual({ start: "2026-03-23T08:00:00.000Z", end: "2026-03-30T07:00:00.000Z" }) + expect(shift === null ? 0 : (shift.endMs - shift.startMs) / HOUR).toBe(167) + expect(who(onCallAt(weekly, [], at("2026-03-30T07:00:00Z")))).toBe("ben") + }) + + it("keeps the 09:00 handoff across fall back, giving a 169h shift", () => { + const shift = onCallAt(weekly, [], at("2026-10-25T12:00:00Z")) + // 33 weeks after the anchor, so the rotation is back at Ana. + expect(who(shift)).toBe("ana") + expect(span(shift)).toEqual({ start: "2026-10-19T07:00:00.000Z", end: "2026-10-26T08:00:00.000Z" }) + expect(shift === null ? 0 : (shift.endMs - shift.startMs) / HOUR).toBe(169) + }) +}) + +describe("daily and custom rotations", () => { + it("hands off daily at the local handoff time", () => { + const daily = decodeConfig({ + timeZone: "America/New_York", + layers: [ + { + name: "Daily", + participants: ["ana", "ben"], + rotation: { kind: "daily", handoffTime: "08:30" }, + startsAt: at("2026-01-05T13:30:00Z"), + }, + ], + }) + expect(who(onCallAt(daily, [], at("2026-01-06T13:29:00Z")))).toBe("ana") + const second = onCallAt(daily, [], at("2026-01-06T13:30:00Z")) + expect(who(second)).toBe("ben") + expect(span(second)).toEqual({ start: "2026-01-06T13:30:00.000Z", end: "2026-01-07T13:30:00.000Z" }) + // After the March 8 change 08:30 EDT is 12:30Z. + expect(span(onCallAt(daily, [], at("2026-03-10T00:00:00Z")))).toEqual({ + start: "2026-03-09T12:30:00.000Z", + end: "2026-03-10T12:30:00.000Z", + }) + }) + + it("runs custom rotations in elapsed hours from the anchor, ignoring DST", () => { + const custom = decodeConfig({ + timeZone: "Europe/Berlin", + layers: [ + { + name: "12h", + participants: ["ana", "ben", "chen"], + rotation: { kind: "custom", lengthHours: 12 }, + startsAt: at("2026-03-28T20:00:00Z"), + }, + ], + }) + expect(who(onCallAt(custom, [], at("2026-03-28T21:00:00Z")))).toBe("ana") + const second = onCallAt(custom, [], at("2026-03-29T10:00:00Z")) + expect(who(second)).toBe("ben") + expect(span(second)).toEqual({ start: "2026-03-29T08:00:00.000Z", end: "2026-03-29T20:00:00.000Z" }) + expect(who(onCallAt(custom, [], at("2026-03-30T07:00:00Z")))).toBe("chen") + expect(who(onCallAt(custom, [], at("2026-03-30T09:00:00Z")))).toBe("ana") + }) +}) + +describe("layers and restrictions", () => { + const layered = decodeConfig({ + timeZone: "Europe/Berlin", + layers: [ + weeklyLayer, + { + name: "Weekend", + participants: ["dana"], + rotation: { kind: "weekly", handoffWeekday: "mon", handoffTime: "09:00" }, + startsAt: WEEKLY_ANCHOR, + restrictions: [{ weekdays: ["sat", "sun"], startTime: "00:00", endTime: "00:00" }], + }, + ], + }) + + it("lets the weekend layer win on weekends and the base layer elsewhere", () => { + const weekday = onCallAt(layered, [], at("2026-03-04T12:00:00Z")) + expect(weekday?.source).toEqual({ kind: "layer", layerIndex: 0 }) + expect(who(weekday)).toBe("ana") + + const weekend = onCallAt(layered, [], at("2026-03-07T12:00:00Z")) + expect(weekend?.source).toEqual({ kind: "layer", layerIndex: 1 }) + expect(who(weekend)).toBe("dana") + expect(span(weekend)).toEqual({ start: "2026-03-06T23:00:00.000Z", end: "2026-03-08T23:00:00.000Z" }) + + // Monday before the 09:00 handoff falls back to the base layer. + const monday = onCallAt(layered, [], at("2026-03-09T05:00:00Z")) + expect(who(monday)).toBe("ana") + expect(span(monday)).toEqual({ start: "2026-03-08T23:00:00.000Z", end: "2026-03-09T08:00:00.000Z" }) + }) + + it("renders each layer's own shifts after restrictions", () => { + const render = renderOnCall(layered, [], at("2026-03-02T08:00:00Z"), at("2026-03-16T08:00:00Z")) + expect(Arr.map(render.layers[1] ?? [], span)).toEqual([ + { start: "2026-03-06T23:00:00.000Z", end: "2026-03-08T23:00:00.000Z" }, + { start: "2026-03-13T23:00:00.000Z", end: "2026-03-15T23:00:00.000Z" }, + ]) + expect(Arr.map(render.final, who)).toEqual(["ana", "dana", "ana", "ben", "dana", "ben"]) + }) + + const nights = decodeConfig({ + timeZone: "UTC", + layers: [ + { + name: "Nights", + participants: ["ana"], + rotation: { kind: "daily", handoffTime: "00:00" }, + startsAt: at("2026-01-01T00:00:00Z"), + restrictions: [ + { + weekdays: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], + startTime: "22:00", + endTime: "06:00", + }, + ], + }, + ], + }) + + it("covers a restriction window that crosses midnight", () => { + const night = onCallAt(nights, [], at("2026-01-06T03:00:00Z")) + expect(who(night)).toBe("ana") + // Pieces of consecutive daily shifts for the same user merge into one window. + expect(span(night)).toEqual({ start: "2026-01-05T22:00:00.000Z", end: "2026-01-06T06:00:00.000Z" }) + }) + + it("reports a gap where no layer is active", () => { + expect(onCallAt(nights, [], at("2026-01-06T12:00:00Z"))).toBeNull() + const render = renderOnCall(nights, [], at("2026-01-06T00:00:00Z"), at("2026-01-07T00:00:00Z")) + expect(Arr.map(render.final, span)).toEqual([ + { start: "2026-01-06T00:00:00.000Z", end: "2026-01-06T06:00:00.000Z" }, + { start: "2026-01-06T22:00:00.000Z", end: "2026-01-07T00:00:00.000Z" }, + ]) + }) +}) + +describe("overrides", () => { + const dan = { + id: "ovr_dan", + userId: "dan", + startsAt: at("2026-03-04T10:00:00Z"), + endsAt: at("2026-03-04T14:00:00Z"), + createdAt: 1, + } + const eve = { + id: "ovr_eve", + userId: "eve", + startsAt: at("2026-03-04T12:00:00Z"), + endsAt: at("2026-03-04T16:00:00Z"), + createdAt: 2, + } + + it("beats every layer and splits the shift it sits inside", () => { + const overrides = decodeOverrides([dan]) + const covered = onCallAt(weekly, overrides, at("2026-03-04T12:00:00Z")) + expect(who(covered)).toBe("dan") + expect(covered?.source).toEqual({ kind: "override", overrideId: "ovr_dan" }) + + const render = renderOnCall(weekly, overrides, WEEKLY_ANCHOR, at("2026-03-09T08:00:00Z")) + expect(Arr.map(render.final, (s) => ({ user: who(s), ...span(s) }))).toEqual([ + { user: "ana", start: "2026-03-02T08:00:00.000Z", end: "2026-03-04T10:00:00.000Z" }, + { user: "dan", start: "2026-03-04T10:00:00.000Z", end: "2026-03-04T14:00:00.000Z" }, + { user: "ana", start: "2026-03-04T14:00:00.000Z", end: "2026-03-09T08:00:00.000Z" }, + ]) + }) + + it("resolves overlapping overrides to the most recently created", () => { + const newerEve = decodeOverrides([dan, eve]) + expect(who(onCallAt(weekly, newerEve, at("2026-03-04T11:00:00Z")))).toBe("dan") + expect(who(onCallAt(weekly, newerEve, at("2026-03-04T13:00:00Z")))).toBe("eve") + + const olderEve = decodeOverrides([dan, { ...eve, createdAt: 0 }]) + expect(who(onCallAt(weekly, olderEve, at("2026-03-04T13:00:00Z")))).toBe("dan") + expect(who(onCallAt(weekly, olderEve, at("2026-03-04T15:00:00Z")))).toBe("eve") + }) + + it("fills a gap", () => { + const nights = decodeConfig({ + timeZone: "UTC", + layers: [ + { + name: "Nights", + participants: ["ana"], + rotation: { kind: "daily", handoffTime: "00:00" }, + startsAt: 0, + restrictions: [{ weekdays: ["tue"], startTime: "22:00", endTime: "06:00" }], + }, + ], + }) + const overrides = decodeOverrides([{ ...dan, startsAt: at("2026-03-04T10:00:00Z") }]) + expect(onCallAt(nights, [], at("2026-03-04T12:00:00Z"))).toBeNull() + expect(who(onCallAt(nights, overrides, at("2026-03-04T12:00:00Z")))).toBe("dan") + }) +}) + +describe("render and onCallAt agree", () => { + it("matches at instants sampled every 37 minutes over 60 days", () => { + const config = decodeConfig({ + timeZone: "Europe/Berlin", + layers: [ + weeklyLayer, + { + name: "Evenings", + participants: ["dana", "eli"], + rotation: { kind: "daily", handoffTime: "18:00" }, + startsAt: at("2026-03-10T17:00:00Z"), + restrictions: [{ weekdays: ["mon", "wed", "fri"], startTime: "18:00", endTime: "02:00" }], + }, + { + name: "Saturday mornings", + participants: ["fay"], + rotation: { kind: "custom", lengthHours: 7 }, + startsAt: at("2026-03-01T00:00:00Z"), + restrictions: [{ weekdays: ["sat"], startTime: "06:00", endTime: "12:00" }], + }, + ], + }) + const overrides = decodeOverrides([ + { + id: "o1", + userId: "gus", + startsAt: at("2026-03-20T10:00:00Z"), + endsAt: at("2026-03-22T10:00:00Z"), + createdAt: 1, + }, + { + id: "o2", + userId: "hal", + startsAt: at("2026-03-21T00:00:00Z"), + endsAt: at("2026-03-21T06:00:00Z"), + createdAt: 2, + }, + { + id: "o3", + userId: "ana", + startsAt: at("2026-04-02T00:00:00Z"), + endsAt: at("2026-04-03T00:00:00Z"), + createdAt: 3, + }, + ]) + // Starts before the base layer, so the first sampled day is a gap. + const fromMs = at("2026-03-01T00:00:00Z") + const toMs = fromMs + 60 * 24 * HOUR + const render = renderOnCall(config, overrides, fromMs, toMs) + const samples = Arr.makeBy( + Math.floor((toMs - fromMs) / (37 * 60_000)), + (i) => fromMs + i * 37 * 60_000, + ) + + const mismatches = Arr.filter(samples, (t) => { + const expected = Arr.findFirst(render.final, (s) => s.startMs <= t && t < s.endMs).pipe( + Option.getOrNull, + ) + const actual = onCallAt(config, overrides, t) + if (expected === null || actual === null) return expected !== actual + const sameOwner = expected.userId === actual.userId && sourceKey(expected) === sourceKey(actual) + // Bounds agree whenever neither window clips the shift. + const unclipped = + expected.startMs > Math.max(fromMs, t - ONCALL_LOOKAROUND_MS) && + expected.endMs < Math.min(toMs, t + ONCALL_LOOKAROUND_MS) + const sameBounds = + !unclipped || (expected.startMs === actual.startMs && expected.endMs === actual.endMs) + return !(sameOwner && sameBounds && actual.startMs <= t && t < actual.endMs) + }) + expect(mismatches).toEqual([]) + // The sample exercises gaps, every layer and the overrides. + const owners = new Set(Arr.map(samples, (t) => who(onCallAt(config, overrides, t)))) + expect(owners).toEqual(new Set([null, "ana", "ben", "chen", "dana", "eli", "fay", "gus", "hal"])) + }) +}) + +describe("render caps", () => { + it("clamps the range to 90 days", () => { + const render = renderOnCall(weekly, [], WEEKLY_ANCHOR, WEEKLY_ANCHOR + 400 * 24 * HOUR) + const last = Arr.last(render.final).pipe(Option.getOrNull) + expect(last === null ? 0 : last.endMs).toBe(WEEKLY_ANCHOR + 90 * 24 * HOUR) + }) + + it("returns nothing for an empty range", () => { + expect(renderOnCall(weekly, [], WEEKLY_ANCHOR, WEEKLY_ANCHOR).final).toEqual([]) + }) +}) + +describe("schema", () => { + const decodes = (input: unknown) => Option.isSome(Schema.decodeUnknownOption(OnCallScheduleConfig)(input)) + + it("accepts a valid config", () => { + expect(decodes({ timeZone: "Europe/Berlin", layers: [weeklyLayer] })).toBe(true) + }) + + it("rejects an unknown time zone", () => { + expect(decodes({ timeZone: "Mars/Olympus", layers: [weeklyLayer] })).toBe(false) + }) + + it("rejects a malformed local time", () => { + const layer = { + ...weeklyLayer, + restrictions: [{ weekdays: ["mon"], startTime: "09:00", endTime: "24:00" }], + } + expect(decodes({ timeZone: "UTC", layers: [layer] })).toBe(false) + expect( + decodes({ + timeZone: "UTC", + layers: [{ ...weeklyLayer, rotation: { kind: "daily", handoffTime: "9:00" } }], + }), + ).toBe(false) + }) + + it("rejects empty participants and empty layers", () => { + expect(decodes({ timeZone: "UTC", layers: [{ ...weeklyLayer, participants: [] }] })).toBe(false) + expect(decodes({ timeZone: "UTC", layers: [] })).toBe(false) + }) + + it("rejects an override that ends before it starts", () => { + const decodeOverride = Schema.decodeUnknownOption(OnCallOverride) + expect( + Option.isNone(decodeOverride({ id: "o", userId: "ana", startsAt: 10, endsAt: 10, createdAt: 0 })), + ).toBe(true) + }) +}) diff --git a/packages/alerting-core/src/oncall.ts b/packages/alerting-core/src/oncall.ts new file mode 100644 index 0000000000..5fb025c02a --- /dev/null +++ b/packages/alerting-core/src/oncall.ts @@ -0,0 +1,322 @@ +import { Array as Arr, DateTime, Option, Order, String as Str } from "effect" +import type { + OnCallLayer, + OnCallOverride, + OnCallRestriction, + OnCallRotation, + OnCallScheduleConfig, + OnCallWeekday, +} from "@maple/domain/http" +import type { UserId } from "@maple/domain/primitives" + +// Pure on-call resolution. Handoffs and restrictions are local wall-clock times in the +// schedule's zone. A local time inside a DST gap moves forward by the gap (02:30 becomes +// 03:30); a repeated local time resolves to its first occurrence (Temporal "compatible"). + +/** Render ranges are clamped to this length past `fromMs`. */ +export const MAX_ONCALL_RENDER_RANGE_MS = 90 * 86_400_000 +/** Each layer yields at most this many rotation shifts per render; later ones are dropped. */ +export const MAX_ONCALL_SHIFTS_PER_LAYER = 5_000 +/** `onCallAt` resolves within this distance of the instant, so its bounds are clamped to it. */ +export const ONCALL_LOOKAROUND_MS = 15 * 86_400_000 + +export type OnCallSource = + | { readonly kind: "override"; readonly overrideId: string } + | { readonly kind: "layer"; readonly layerIndex: number } + +export interface OnCallShift { + readonly userId: UserId + readonly startMs: number + readonly endMs: number + readonly source: OnCallSource +} + +export interface OnCallRender { + /** Each layer's own shifts after its restrictions, clipped to the range. */ + readonly layers: ReadonlyArray> + /** The effective schedule after layering and overrides. */ + readonly final: ReadonlyArray +} + +interface Interval { + readonly startMs: number + readonly endMs: number +} + +const DAY_MS = 86_400_000 +const HOUR_MS = 3_600_000 + +const WEEKDAYS: ReadonlyArray = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] +const weekdayIndex = (weekday: OnCallWeekday): number => + Arr.findFirstIndex(WEEKDAYS, (d) => d === weekday).pipe(Option.getOrElse(() => 0)) + +const mod = (n: number, m: number): number => ((n % m) + m) % m + +// Civil days are counted from 1970-01-01 (a Thursday), independent of any zone. +const weekdayOfDay = (day: number): number => mod(day + 3, 7) + +const parseLocalTime = (time: string) => ({ + hour: Number(Str.slice(0, 2)(time)), + minute: Number(Str.slice(3, 5)(time)), +}) + +const localDayOf = (zone: DateTime.TimeZone, ms: number): number => { + const parts = DateTime.toParts(DateTime.makeZonedUnsafe(ms, { timeZone: zone })) + return Math.floor(Date.UTC(parts.year, parts.month - 1, parts.day) / DAY_MS) +} + +/** The instant of a local wall-clock time on a civil day. */ +const localTimeOnDay = (zone: DateTime.TimeZone, day: number, time: string): number => { + const civil = DateTime.toPartsUtc(DateTime.makeUnsafe(day * DAY_MS)) + const { hour, minute } = parseLocalTime(time) + return DateTime.makeZoned( + { year: civil.year, month: civil.month, day: civil.day, hour, minute }, + { timeZone: zone, adjustForTimeZone: true, disambiguation: "compatible" }, + ).pipe( + Option.map(DateTime.toEpochMillis), + Option.getOrElse(() => day * DAY_MS + hour * HOUR_MS + minute * 60_000), + ) +} + +interface RotationClock { + /** Index of the rotation shift containing `ms` (0 is the shift containing the anchor). */ + readonly indexAt: (ms: number) => number + readonly bounds: (index: number) => Interval +} + +const rotationClock = ( + zone: DateTime.TimeZone, + rotation: OnCallRotation, + anchorMs: number, +): RotationClock => { + if (rotation.kind === "custom") { + const lengthMs = rotation.lengthHours * HOUR_MS + return { + indexAt: (ms) => Math.floor((ms - anchorMs) / lengthMs), + bounds: (index) => ({ + startMs: anchorMs + index * lengthMs, + endMs: anchorMs + (index + 1) * lengthMs, + }), + } + } + const step = rotation.kind === "daily" ? 1 : 7 + const weekdayOffset = (day: number) => + rotation.kind === "daily" ? 0 : mod(weekdayOfDay(day) - weekdayIndex(rotation.handoffWeekday), 7) + // The handoff day of the shift containing `ms`. + const handoffDayAt = (ms: number): number => { + const day = localDayOf(zone, ms) + const candidate = day - weekdayOffset(day) + return localTimeOnDay(zone, candidate, rotation.handoffTime) <= ms ? candidate : candidate - step + } + const anchorDay = handoffDayAt(anchorMs) + return { + indexAt: (ms) => Math.round((handoffDayAt(ms) - anchorDay) / step), + bounds: (index) => ({ + startMs: localTimeOnDay(zone, anchorDay + index * step, rotation.handoffTime), + endMs: localTimeOnDay(zone, anchorDay + (index + 1) * step, rotation.handoffTime), + }), + } +} + +const byStart = Order.mapInput(Order.Number, (interval: Interval) => interval.startMs) + +const mergeIntervals = (intervals: ReadonlyArray): Array => + Arr.reduce(Arr.sort(intervals, byStart), Arr.empty(), (acc, next) => { + const last = acc[acc.length - 1] + // Accumulators are local to the fold, so in-place updates keep it linear. + if (last !== undefined && next.startMs <= last.endMs) { + acc[acc.length - 1] = { startMs: last.startMs, endMs: Math.max(last.endMs, next.endMs) } + } else { + acc.push(next) + } + return acc + }) + +/** Active windows of a layer's restrictions overlapping [fromMs, toMs). */ +const restrictionWindows = ( + zone: DateTime.TimeZone, + restrictions: ReadonlyArray, + fromMs: number, + toMs: number, +): Array => { + // Start a day early so a window crossing midnight into the range is included. + const days = Arr.range(localDayOf(zone, fromMs) - 1, localDayOf(zone, toMs)) + const raw = Arr.flatMap(days, (day) => + Arr.flatMap(restrictions, (restriction) => { + const weekday = WEEKDAYS[weekdayOfDay(day)] + if (!Arr.some(restriction.weekdays, (d) => d === weekday)) return [] + const crossesMidnight = restriction.endTime <= restriction.startTime + const window = { + startMs: localTimeOnDay(zone, day, restriction.startTime), + endMs: localTimeOnDay(zone, crossesMidnight ? day + 1 : day, restriction.endTime), + } + return window.endMs > window.startMs ? [window] : [] + }), + ) + return Arr.filter(mergeIntervals(raw), (w) => w.endMs > fromMs && w.startMs < toMs) +} + +const clip = (interval: Interval, fromMs: number, toMs: number): Option.Option => { + const startMs = Math.max(interval.startMs, fromMs) + const endMs = Math.min(interval.endMs, toMs) + return endMs > startMs ? Option.some({ startMs, endMs }) : Option.none() +} + +const layerShifts = ( + zone: DateTime.TimeZone, + layer: OnCallLayer, + layerIndex: number, + fromMs: number, + toMs: number, +): Array => { + const startMs = Math.max(fromMs, layer.startsAt) + if (startMs >= toMs) return [] + const clock = rotationClock(zone, layer.rotation, layer.startsAt) + const first = clock.indexAt(startMs) + const count = Math.min(clock.indexAt(toMs - 1) - first + 1, MAX_ONCALL_SHIFTS_PER_LAYER) + const source: OnCallSource = { kind: "layer", layerIndex } + const rotationShifts = Arr.flatMap( + Arr.makeBy(Math.max(count, 1), (i) => first + i), + (index) => + clip(clock.bounds(index), startMs, toMs).pipe( + Option.map((interval) => ({ + ...interval, + userId: + layer.participants[mod(index, layer.participants.length)] ?? layer.participants[0], + source, + })), + Option.toArray, + ), + ) + const restrictions = layer.restrictions ?? [] + if (!Arr.isReadonlyArrayNonEmpty(restrictions)) return rotationShifts + const windows = restrictionWindows(zone, restrictions, startMs, toMs) + return Arr.flatMap(rotationShifts, (shift) => + Arr.flatMap(windows, (window) => + clip(window, shift.startMs, shift.endMs).pipe( + Option.map((piece) => ({ ...shift, ...piece })), + Option.toArray, + ), + ), + ) +} + +/** Binary search over sorted, disjoint shifts. */ +const shiftCovering = ( + shifts: ReadonlyArray, + ms: number, + lo = 0, + hi = shifts.length - 1, +): Option.Option => { + if (lo > hi) return Option.none() + const mid = Math.floor((lo + hi) / 2) + const shift = shifts[mid] + if (shift === undefined) return Option.none() + if (ms < shift.startMs) return shiftCovering(shifts, ms, lo, mid - 1) + if (ms >= shift.endMs) return shiftCovering(shifts, ms, mid + 1, hi) + return Option.some(shift) +} + +// Most recently created wins; the id breaks ties so the choice is deterministic. +const overrideOrder = Order.combine( + Order.mapInput(Order.Number, (o: OnCallOverride) => o.createdAt), + Order.mapInput(Order.String, (o: OnCallOverride) => o.id), +) + +const sameSource = (a: OnCallSource, b: OnCallSource): boolean => + a.kind === "override" + ? b.kind === "override" && a.overrideId === b.overrideId + : b.kind === "layer" && a.layerIndex === b.layerIndex + +const resolveZone = (config: OnCallScheduleConfig): DateTime.TimeZone => + // A decoded config always names a valid zone; UTC only guards undecoded input. + Option.getOrElse(DateTime.zoneMakeNamed(config.timeZone), () => DateTime.zoneMakeOffset(0)) + +/** + * Render each layer and the effective schedule over [fromMs, toMs). The range is clamped + * to MAX_ONCALL_RENDER_RANGE_MS and each layer to MAX_ONCALL_SHIFTS_PER_LAYER shifts. + */ +export const renderOnCall = ( + config: OnCallScheduleConfig, + overrides: ReadonlyArray, + fromMs: number, + toMs: number, +): OnCallRender => { + const endMs = Math.min(toMs, fromMs + MAX_ONCALL_RENDER_RANGE_MS) + if (endMs <= fromMs) return { layers: Arr.map(config.layers, () => []), final: [] } + const zone = resolveZone(config) + const layers = Arr.map(config.layers, (layer, index) => layerShifts(zone, layer, index, fromMs, endMs)) + const activeOverrides = Arr.filter( + overrides, + (o) => o.endsAt > o.startsAt && o.endsAt > fromMs && o.startsAt < endMs, + ) + + // Every boundary in play, so the winner is constant on each elementary segment. + const boundaries = Arr.dedupeAdjacent( + Arr.sort( + Arr.flatten([ + [fromMs, endMs], + Arr.flatMap(Arr.flatten(layers), (s) => [s.startMs, s.endMs]), + Arr.flatMap(activeOverrides, (o) => [ + Math.max(o.startsAt, fromMs), + Math.min(o.endsAt, endMs), + ]), + ]), + Order.Number, + ), + ) + const segments = Arr.zipWith( + Arr.dropRight(boundaries, 1), + Arr.drop(boundaries, 1), + (startMs, segEnd) => ({ + startMs, + endMs: segEnd, + }), + ) + + const winnerAt = (ms: number): Option.Option> => { + const covering = Arr.filter(activeOverrides, (o) => o.startsAt <= ms && ms < o.endsAt) + if (Arr.isArrayNonEmpty(covering)) { + const winner = Arr.max(covering, overrideOrder) + return Option.some({ userId: winner.userId, source: { kind: "override", overrideId: winner.id } }) + } + return Arr.findFirst(Arr.reverse(layers), (shifts) => shiftCovering(shifts, ms)) + } + + const final = Arr.reduce(segments, Arr.empty(), (acc, segment) => + Option.match(winnerAt(segment.startMs), { + onNone: () => acc, + onSome: (winner) => { + const last = acc[acc.length - 1] + if ( + last !== undefined && + last.endMs === segment.startMs && + last.userId === winner.userId && + sameSource(last.source, winner.source) + ) { + acc[acc.length - 1] = { ...last, endMs: segment.endMs } + } else { + acc.push({ ...winner, ...segment }) + } + return acc + }, + }), + ) + return { layers, final } +} + +/** + * Who is on call at `atMs`, with the bounds of the effective shift containing it (clamped + * to ONCALL_LOOKAROUND_MS either side). Null is a gap: nobody is on call. + */ +export const onCallAt = ( + config: OnCallScheduleConfig, + overrides: ReadonlyArray, + atMs: number, +): OnCallShift | null => + Option.getOrNull( + shiftCovering( + renderOnCall(config, overrides, atMs - ONCALL_LOOKAROUND_MS, atMs + ONCALL_LOOKAROUND_MS).final, + atMs, + ), + ) diff --git a/packages/domain/src/http/index.ts b/packages/domain/src/http/index.ts index aa2316bb86..426188c7b3 100644 --- a/packages/domain/src/http/index.ts +++ b/packages/domain/src/http/index.ts @@ -20,6 +20,7 @@ export * from "./device-credentials" export * from "./audit-log" export * from "./alerts" export * from "./escalation-chains" +export * from "./oncall-schedules" export * from "./agent-feedback" export * from "./mcp-connections" export * from "./mcp-tools" diff --git a/packages/domain/src/http/oncall-schedules.ts b/packages/domain/src/http/oncall-schedules.ts new file mode 100644 index 0000000000..8d61f52f48 --- /dev/null +++ b/packages/domain/src/http/oncall-schedules.ts @@ -0,0 +1,87 @@ +import { DateTime, Option, Schema } from "effect" +import { UserId } from "../primitives" + +export const MAX_ONCALL_LAYERS = 5 +export const MAX_ONCALL_PARTICIPANTS = 50 +export const MAX_ONCALL_RESTRICTIONS = 7 + +/** A local wall-clock time, "00:00" through "23:59". */ +export const OnCallLocalTime = Schema.String.check( + Schema.isPattern(/^([01]\d|2[0-3]):[0-5]\d$/u, { identifier: "OnCallLocalTime" }), +) +export type OnCallLocalTime = Schema.Schema.Type + +export const OnCallWeekday = Schema.Literals(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]) +export type OnCallWeekday = Schema.Schema.Type + +/** An IANA zone the runtime can resolve. */ +export const OnCallTimeZone = Schema.String.check( + Schema.isMaxLength(64), + Schema.makeFilter((zone: string) => Option.isSome(DateTime.zoneMakeNamed(zone)) || "unknown time zone", { + identifier: "OnCallTimeZone", + }), +) + +const EpochMillis = Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)) + +export const OnCallDailyRotation = Schema.Struct({ + kind: Schema.Literal("daily"), + handoffTime: OnCallLocalTime, +}) + +export const OnCallWeeklyRotation = Schema.Struct({ + kind: Schema.Literal("weekly"), + handoffWeekday: OnCallWeekday, + handoffTime: OnCallLocalTime, +}) + +/** Fixed-length shifts in elapsed hours, anchored at the layer's `startsAt`. */ +export const OnCallCustomRotation = Schema.Struct({ + kind: Schema.Literal("custom"), + lengthHours: Schema.Number.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: 336 })), +}) + +export const OnCallRotation = Schema.Union([OnCallDailyRotation, OnCallWeeklyRotation, OnCallCustomRotation]) +export type OnCallRotation = Schema.Schema.Type + +/** A weekly local window; `endTime <= startTime` crosses midnight into the next day. */ +export const OnCallRestriction = Schema.Struct({ + weekdays: Schema.NonEmptyArray(OnCallWeekday).check(Schema.isMaxLength(7)), + startTime: OnCallLocalTime, + endTime: OnCallLocalTime, +}) +export type OnCallRestriction = Schema.Schema.Type + +export const OnCallLayer = Schema.Struct({ + name: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(100)), + participants: Schema.NonEmptyArray(UserId).check(Schema.isMaxLength(MAX_ONCALL_PARTICIPANTS)), + rotation: OnCallRotation, + /** Rotation anchor: participant 0 holds the shift containing this instant. */ + startsAt: EpochMillis, + /** Absent or empty means the layer is always active. */ + restrictions: Schema.optionalKey( + Schema.Array(OnCallRestriction).check(Schema.isMaxLength(MAX_ONCALL_RESTRICTIONS)), + ), +}) +export type OnCallLayer = Schema.Schema.Type + +/** Later layers sit on top: where a higher layer has someone on call, it wins. */ +export const OnCallScheduleConfig = Schema.Struct({ + timeZone: OnCallTimeZone, + layers: Schema.NonEmptyArray(OnCallLayer).check(Schema.isMaxLength(MAX_ONCALL_LAYERS)), +}) +export type OnCallScheduleConfig = Schema.Schema.Type + +/** Beats every layer; among overlapping overrides the most recently created wins. */ +export const OnCallOverride = Schema.Struct({ + id: Schema.String.check(Schema.isMinLength(1)), + userId: UserId, + startsAt: EpochMillis, + endsAt: EpochMillis, + createdAt: EpochMillis, +}).check( + Schema.makeFilter((o) => o.endsAt > o.startsAt || "endsAt must be after startsAt", { + identifier: "OnCallOverrideRange", + }), +) +export type OnCallOverride = Schema.Schema.Type From 39243996d609b945c270257a7ccbd948481f62b3 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 13:12:40 +0100 Subject: [PATCH 14/98] fix(alerts): use Arr.findFirst for the delivery log's connector lookup Same change as #2302, so CI's effect lint passes on this stack before it lands. --- apps/web/src/page/alerts/hub/view-routing.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/page/alerts/hub/view-routing.ts b/apps/web/src/page/alerts/hub/view-routing.ts index 5c2fd2f020..64592dcc23 100644 --- a/apps/web/src/page/alerts/hub/view-routing.ts +++ b/apps/web/src/page/alerts/hub/view-routing.ts @@ -456,7 +456,12 @@ const logRow = (h: H, model: Model, inputs: RoutingInputs, event: AlertDeliveryE const ev = eventTypeMeta[event.eventType] const status = deliveryStatusMeta[event.status] // Delivery events carry only the type; a `chat` destination's logo comes from its connector. - const chatConnector = inputs.destinations.find((d) => d.id === event.destinationId)?.chatConnector + const chatConnector = Option.getOrUndefined( + Option.flatMapNullishOr( + Arr.findFirst(inputs.destinations, (d) => d.id === event.destinationId), + (d) => d.chatConnector, + ), + ) return tableRow(h, [ tableCell(h, [ hstack(h, { gap: "1.5", as: "span" }, [ From 941605873f62929681ff61f21e4cd69df26e8b0b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 13:23:45 +0100 Subject: [PATCH 15/98] feat(alerts): v2 alert incidents carry their latest escalation run The incident document gains a nullable escalation object (chain, status, next step, pass, next page time) read from alert_escalation_runs in one batched query per page. --- .../api/src/routes/v2/alert-incidents.http.ts | 60 ++++++++-- apps/api/src/routes/v2/alerts.http.test.ts | 88 ++++++++++++++ apps/api/src/routes/v2/v2-test-support.ts | 1 + .../MapleAPI/Sources/MapleAPI/openapi.json | 110 ++++++++++++++++++ .../services/alerts/AlertReadModelsService.ts | 65 ++++++++++- .../src/services/alerts/AlertsService.ts | 1 + packages/domain/src/http/escalation-chains.ts | 25 +++- .../domain/src/http/v2/alert-incidents.ts | 51 ++++++++ .../domain/src/http/v2/v2-contract.test.ts | 33 ++++++ 9 files changed, 424 insertions(+), 10 deletions(-) diff --git a/apps/api/src/routes/v2/alert-incidents.http.ts b/apps/api/src/routes/v2/alert-incidents.http.ts index 9235babcda..87e6874e5c 100644 --- a/apps/api/src/routes/v2/alert-incidents.http.ts +++ b/apps/api/src/routes/v2/alert-incidents.http.ts @@ -1,7 +1,13 @@ import { HttpApiBuilder } from "effect/http-api" -import type { AlertIncidentDocument, AlertIncidentEventDocument } from "@maple/domain/http" +import type { + AlertIncidentDocument, + AlertIncidentEscalationDocument, + AlertIncidentEventDocument, + AlertIncidentId, + OrgId, +} from "@maple/domain/http" import { CurrentTenant } from "@maple/domain/http" -import type { V2AlertIncident, V2AlertIncidentEvent } from "@maple/domain/http/v2" +import type { V2AlertIncident, V2AlertIncidentEscalation, V2AlertIncidentEvent } from "@maple/domain/http/v2" import { MapleApiV2, paginateOffsetQuery } from "@maple/domain/http/v2" import { Duration, Effect, Option, Array as Arr } from "effect" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" @@ -12,7 +18,23 @@ import { import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" import { CurrentAuditActor } from "@maple/backend/services/auth/audit-actor" -const toV2Incident = (doc: AlertIncidentDocument): V2AlertIncident => ({ +const toV2Escalation = (doc: AlertIncidentEscalationDocument): V2AlertIncidentEscalation => ({ + chain_id: doc.chainId, + chain_name: doc.chainName, + status: doc.status, + // The scheduler leaves an exhausted run past its last step; the wire never reads past the end. + step: Math.min(doc.stepIndex + 1, doc.stepCount), + step_count: doc.stepCount, + pass: Math.min(doc.passIndex + 1, doc.repeatCount + 1), + pass_count: doc.repeatCount + 1, + steps_sent: doc.stepsSent, + next_step_at: doc.nextStepAt, +}) + +const toV2Incident = ( + doc: AlertIncidentDocument, + escalation: AlertIncidentEscalationDocument | undefined, +): V2AlertIncident => ({ id: doc.id, object: "alert_incident", rule_id: doc.ruleId, @@ -42,6 +64,7 @@ const toV2Incident = (doc: AlertIncidentDocument): V2AlertIncident => ({ : { user_id: doc.acknowledgedByUserId, name: doc.acknowledgedByName }, acknowledged_via: doc.acknowledgedVia, error_issue_id: doc.errorIssueId, + escalation: escalation === undefined ? null : toV2Escalation(escalation), }) const toV2Event = (doc: AlertIncidentEventDocument): V2AlertIncidentEvent => ({ @@ -63,6 +86,24 @@ export const HttpV2AlertIncidentsLive = HttpApiBuilder.group(MapleApiV2, "alertI const acks = yield* AlertIncidentAckService const members = yield* OrgMembersService + // One escalation read for a whole page of incidents, never one per incident. + const withEscalations = (orgId: OrgId, docs: ReadonlyArray) => + readModels + .listIncidentEscalations( + orgId, + Arr.map(docs, (doc): AlertIncidentId => doc.id), + ) + .pipe( + Effect.map((escalations) => { + const byIncident = new Map(Arr.map(escalations, (row) => [row.incidentId, row])) + return Arr.map(docs, (doc) => toV2Incident(doc, byIncident.get(doc.id))) + }), + ) + const withEscalation = (orgId: OrgId, doc: AlertIncidentDocument) => + Effect.map(readModels.listIncidentEscalations(orgId, [doc.id]), (rows) => + toV2Incident(doc, rows[0]), + ) + // Who is acknowledging, with a name recorded alongside so every surface can show it without a // directory lookup. An API key is not a person: it is named by its label and has no user. A // session is named by the member directory, best effort: a slow one records no name. @@ -103,7 +144,11 @@ export const HttpV2AlertIncidentsLive = HttpApiBuilder.group(MapleApiV2, "alertI limit, offset, }) - .pipe(Effect.map((response) => Arr.map(response.incidents, toV2Incident))), + .pipe( + Effect.flatMap((response) => + withEscalations(tenant.orgId, response.incidents), + ), + ), ) return { object: "list" as const, ...page } }), @@ -112,22 +157,21 @@ export const HttpV2AlertIncidentsLive = HttpApiBuilder.group(MapleApiV2, "alertI Effect.gen(function* () { const tenant = yield* CurrentTenant.Context const incident = yield* readModels.getIncident(tenant.orgId, params.id) - - return toV2Incident(incident) + return yield* withEscalation(tenant.orgId, incident) }), ) .handle("acknowledge", ({ params }) => Effect.gen(function* () { const { orgId, actor } = yield* callerActor const result = yield* acks.acknowledge(orgId, params.id, actor) - return toV2Incident(result.incident) + return yield* withEscalation(orgId, result.incident) }).pipe(Effect.withSpan("HttpV2AlertIncidents.acknowledge")), ) .handle("unacknowledge", ({ params }) => Effect.gen(function* () { const { orgId, actor } = yield* callerActor const result = yield* acks.unacknowledge(orgId, params.id, actor) - return toV2Incident(result.incident) + return yield* withEscalation(orgId, result.incident) }).pipe(Effect.withSpan("HttpV2AlertIncidents.unacknowledge")), ) .handle("listEvents", ({ params }) => diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index e5201d1af8..bac2cb9c9e 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -1261,6 +1261,94 @@ describe("v2 alerts over HTTP", () => { await harness.dispose() }) + it("reads an incident's latest escalation run on retrieve and list, and null without one", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(["alerts:write"]) + await createWebhookAndRule(harness, key.secret) + await executeSql( + harness.testDb, + `insert into alert_incidents ( + id, org_id, rule_id, incident_key, rule_name, group_key, signal_type, severity, status, + comparator, threshold, first_triggered_at, last_triggered_at, resolved_at, dedupe_key, + created_at, updated_at + ) + select v.id, rules.org_id, rules.id, v.id, 'Checkout error rate', v.group_key, 'error_rate', + 'critical', 'open', 'gt', 0.05, now(), now(), null, v.id, now(), now() + from (values + ('00000000-0000-4000-8000-0000000000b1', 'checkout'), + ('00000000-0000-4000-8000-0000000000b2', 'payments') + ) as v(id, group_key) + cross join lateral ( + select id, org_id from alert_rules where org_id = 'org_alerts_e2e' limit 1 + ) as rules`, + ) + const steps = JSON.stringify([ + { + targets: [{ type: "destination", destinationId: "00000000-0000-4000-8000-0000000000d1" }], + waitMinutes: 10, + }, + { + targets: [{ type: "destination", destinationId: "00000000-0000-4000-8000-0000000000d2" }], + waitMinutes: 15, + }, + { + targets: [{ type: "destination", destinationId: "00000000-0000-4000-8000-0000000000d3" }], + waitMinutes: 5, + }, + ]) + // An older superseded run and the live one: the read shows the newer. + await executeSql( + harness.testDb, + `insert into alert_escalation_runs ( + id, org_id, incident_id, chain_id, chain_name, severity, steps_json, repeat_count, + step_index, pass_index, next_step_at, status, payload_json, steps_sent, created_at, updated_at + ) values + ('00000000-0000-4000-8000-0000000000c1', 'org_alerts_e2e', '00000000-0000-4000-8000-0000000000b1', + '00000000-0000-4000-8000-0000000000e1', 'Old chain', 'warning', '${steps}'::jsonb, 0, + 1, 0, '2026-11-06T14:00:00.000Z', 'superseded', '{}'::jsonb, 1, + '2026-11-06T13:00:00.000Z', '2026-11-06T13:00:00.000Z'), + ('00000000-0000-4000-8000-0000000000c2', 'org_alerts_e2e', '00000000-0000-4000-8000-0000000000b1', + '00000000-0000-4000-8000-0000000000e2', 'Checkout on-call', 'critical', '${steps}'::jsonb, 1, + 1, 0, '2026-11-06T14:25:00.000Z', 'active', '{}'::jsonb, 1, + '2026-11-06T14:10:00.000Z', '2026-11-06T14:10:00.000Z')`, + ) + const list = await harness.request("GET", "/v2/alerts/incidents?status=open", key.secret) + expect(list.status).toBe(200) + const isEscalating = (incident: { escalation: unknown }) => incident.escalation !== null + const without = Arr.filter(list.body.data, (incident) => !isEscalating(incident)) + const withRuns = Arr.filter(list.body.data, isEscalating) + expect(without).toHaveLength(1) + expect(without[0].escalation).toBeNull() + expect(withRuns).toHaveLength(1) + const withRun = withRuns[0] + const expected = { + chain_id: expect.stringMatching(/^esc_/), + chain_name: "Checkout on-call", + status: "active", + step: 2, + step_count: 3, + pass: 1, + pass_count: 2, + steps_sent: 1, + next_step_at: "2026-11-06T14:25:00.000Z", + } + expect(withRun.escalation).toEqual(expected) + + const retrieved = await harness.request("GET", `/v2/alerts/incidents/${withRun.id}`, key.secret) + expect(retrieved.status).toBe(200) + expect(retrieved.body.escalation).toEqual(expected) + + // Paused while acknowledged: no next page time. + await executeSql( + harness.testDb, + `update alert_escalation_runs set status = 'acknowledged' where id = '00000000-0000-4000-8000-0000000000c2'`, + ) + const paused = await harness.request("GET", `/v2/alerts/incidents/${withRun.id}`, key.secret) + expect(paused.body.escalation).toMatchObject({ status: "acknowledged", next_step_at: null }) + + await harness.dispose() + }) + it("enforces the alerts scope family and rejects malformed ids", async () => { const harness = makeHarness() const readOnly = await harness.bootstrapKey(["alerts:read"]) diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index 3d7ff120b0..e530a41ac1 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -598,6 +598,7 @@ export const AlertDestinationsServiceStubLayer = Layer.succeed( const alertReadModelStubs = { listIncidents: die, getIncident: die, + listIncidentEscalations: die, listRuleChecks: die, summarizeRuleChecks: die, listDeliveryEvents: die, diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index 3431531215..376ad663d5 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -313,6 +313,17 @@ "comparator": "gt", "dedupe_key": "alrt_gU26thvJECdQvu54Ad9jiz:__total__", "error_issue_id": null, + "escalation": { + "chain_id": "esc_gQtGH2s65G6a7sJmuayXL6", + "chain_name": "Checkout on-call", + "next_step_at": null, + "pass": 1, + "pass_count": 2, + "status": "acknowledged", + "step": 2, + "step_count": 3, + "steps_sent": 1 + }, "first_triggered_at": "2026-07-15T09:10:00.000Z", "group_key": null, "held_since": null, @@ -362,6 +373,10 @@ "$ref": "#/components/schemas/_maple_ErrorIssueId", "description": "The linked error issue (`iss_…`) when the incident was correlated to one, or `null`." }, + "escalation": { + "$ref": "#/components/schemas/AlertIncidentEscalation", + "description": "The incident's latest escalation chain run, or `null` when its severity tier names no chain." + }, "first_triggered_at": { "type": "string" }, @@ -484,6 +499,86 @@ "title": "Alert incident actor", "type": "object" }, + "AlertIncidentEscalation": { + "additionalProperties": false, + "description": "How far the incident's latest escalation chain run has got.", + "properties": { + "chain_id": { + "$ref": "#/components/schemas/_maple_EscalationChainId" + }, + "chain_name": { + "description": "The chain's name when the run started.", + "examples": [ + "Checkout on-call" + ], + "type": "string" + }, + "next_step_at": { + "description": "When the next step pages, or `null` while paused or once the run has ended.", + "type": "string" + }, + "pass": { + "description": "The pass the next step belongs to, from 1.", + "examples": [ + 1 + ], + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "pass_count": { + "description": "Passes over the chain in all: its repeat count plus one.", + "examples": [ + 2 + ], + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "status": { + "$ref": "#/components/schemas/_maple_AlertEscalationRunStatus" + }, + "step": { + "description": "The next step to page, from 1.", + "examples": [ + 2 + ], + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "step_count": { + "description": "Steps in the chain the run started with.", + "examples": [ + 3 + ], + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "steps_sent": { + "description": "Steps paged so far, across passes.", + "examples": [ + 1 + ], + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "chain_id", + "chain_name", + "status", + "step", + "step_count", + "pass", + "pass_count", + "steps_sent" + ], + "title": "Alert incident escalation", + "type": "object" + }, "AlertIncidentList": { "additionalProperties": false, "description": "A cursor-paginated page of alert incidents.", @@ -6883,6 +6978,21 @@ "title": "Alert Destination Type", "type": "string" }, + "_maple_AlertEscalationRunStatus": { + "description": "`active` waits on `next_step_at`; `acknowledged` is paused until the incident is unacknowledged; `resolved`, `exhausted` (every step and repeat sent) and `superseded` (a higher tier started its own chain) have ended.", + "enum": [ + "active", + "acknowledged", + "resolved", + "exhausted", + "superseded" + ], + "examples": [ + "active" + ], + "title": "Alert Escalation Run Status", + "type": "string" + }, "_maple_AlertEvaluationStatus": { "description": "The evaluation verdict: `breached`, `healthy`, or `skipped` (not enough samples / no data).", "enum": [ diff --git a/packages/backend/src/services/alerts/AlertReadModelsService.ts b/packages/backend/src/services/alerts/AlertReadModelsService.ts index bfcf088c73..7102e69686 100644 --- a/packages/backend/src/services/alerts/AlertReadModelsService.ts +++ b/packages/backend/src/services/alerts/AlertReadModelsService.ts @@ -10,6 +10,7 @@ import { AlertDestinationType as AlertDestinationTypeSchema, AlertEventType as AlertEventTypeSchema, AlertIncidentDocument, + AlertIncidentEscalationDocument, AlertIncidentsListResponse, AlertIncidentAckSource, AlertIncidentHoldReason, @@ -33,11 +34,22 @@ import * as PG from "@maple-dev/effect-orm/postgres" import { AlertDeliveryEvents, AlertDestinations, + AlertEscalationRuns, AlertIncidents, AlertRules, type AlertIncidentRow, } from "@maple/db/tables" -import { Context, DateTime, Duration, Effect, Layer, Option, Schema } from "effect" +import { + Array as Arr, + Context, + DateTime, + Duration, + Effect, + Layer, + Option, + Record as Rec, + Schema, +} from "effect" import { Database } from "@maple/backend/platform/DatabaseLive" import { makeDbExecute } from "@maple/backend/platform/db-execute" import { makePersistenceError } from "./alert-persistence" @@ -97,6 +109,11 @@ export interface AlertReadModelsServiceApi { orgId: OrgId, incidentId: AlertIncidentId, ) => Effect.Effect + /** Each incident's latest escalation run, in one query for the whole set; incidents without one are absent. */ + readonly listIncidentEscalations: ( + orgId: OrgId, + incidentIds: ReadonlyArray, + ) => Effect.Effect, AlertPersistenceError> readonly listRuleChecks: ( orgId: OrgId, ruleId: AlertRuleId, @@ -269,6 +286,51 @@ export class AlertReadModelsService extends Context.Service< return rowToIncidentDocument(incident) }) + const listIncidentEscalations = Effect.fn("AlertReadModelsService.listIncidentEscalations")( + function* (orgId: OrgId, incidentIds: ReadonlyArray) { + yield* Effect.annotateCurrentSpan({ orgId, "maple.alert.incident_count": incidentIds.length }) + if (incidentIds.length === 0) return [] + const rows = yield* dbExecute((db) => + db.run( + PG.from(AlertEscalationRuns) + .select( + "id", + "createdAt", + "incidentId", + "chainId", + "chainName", + "status", + "stepIndex", + "passIndex", + "stepsJson", + "repeatCount", + "stepsSent", + "nextStepAt", + ) + .where(($) => [$.orgId.eq(orgId), $.incidentId.in_(...incidentIds)]) + .orderBy(["createdAt", "desc"], ["id", "desc"]), + ), + ) + // Newest first, so each incident's group starts with its latest run. + return Arr.map( + Rec.values(Arr.groupBy(rows, (row) => row.incidentId)), + ([row]) => + new AlertIncidentEscalationDocument({ + incidentId: row.incidentId, + chainId: row.chainId, + chainName: row.chainName, + status: row.status, + stepIndex: row.stepIndex, + passIndex: row.passIndex, + stepCount: row.stepsJson.length, + repeatCount: row.repeatCount, + stepsSent: row.stepsSent, + nextStepAt: row.status === "active" ? toIso(row.nextStepAt) : null, + }), + ) + }, + ) + const listRuleChecks = Effect.fn("AlertReadModelsService.listRuleChecks")(function* ( orgId: OrgId, ruleId: AlertRuleId, @@ -539,6 +601,7 @@ export class AlertReadModelsService extends Context.Service< return { listIncidents, getIncident, + listIncidentEscalations, listRuleChecks, summarizeRuleChecks, listDeliveryEvents, diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index e6294a2ba0..934ebcf08f 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -5587,6 +5587,7 @@ export class AlertsService extends Context.Service +/** An incident's latest escalation run, as the incident reads show it. Steps and passes count from 0. */ +export class AlertIncidentEscalationDocument extends Schema.Class( + "AlertIncidentEscalationDocument", +)({ + incidentId: AlertIncidentId, + chainId: EscalationChainId, + chainName: Schema.String, + status: AlertEscalationRunStatus, + stepIndex: Schema.Number, + passIndex: Schema.Number, + stepCount: Schema.Number, + repeatCount: Schema.Number, + stepsSent: Schema.Number, + /** When the next step pages; `null` once the run is no longer `active`. */ + nextStepAt: Schema.NullOr(IsoDateTimeString), +}) {} + export class EscalationChainNotFoundError extends HttpTaggedError()( "@maple/http/errors/EscalationChainNotFoundError", { message: Schema.String, chainId: EscalationChainId }, diff --git a/packages/domain/src/http/v2/alert-incidents.ts b/packages/domain/src/http/v2/alert-incidents.ts index 6b73520577..8e2f1375b0 100644 --- a/packages/domain/src/http/v2/alert-incidents.ts +++ b/packages/domain/src/http/v2/alert-incidents.ts @@ -11,6 +11,7 @@ import { AlertSeverity, AlertSignalType, } from "../alerts" +import { AlertEscalationRunStatus } from "../escalation-chains" import { AuthorizationV2 } from "./auth" import { wireExample, ListOf, ListQuery, Timestamp } from "./envelopes" import { V2ParameterInvalid } from "./errors" @@ -21,6 +22,7 @@ import { AlertIncidentPublicId, AlertRulePublicId, ErrorIssuePublicId, + EscalationChainPublicId, } from "./resource-ids" const alertIncidentExample = { @@ -50,6 +52,17 @@ const alertIncidentExample = { acknowledged_by: { user_id: "user_2Nk8mXqPfR3yZ1aB4cD5eF6g", name: "Dana Kim" }, acknowledged_via: "slack", error_issue_id: null, + escalation: { + chain_id: "esc_gQtGH2s65G6a7sJmuayXL6", + chain_name: "Checkout on-call", + status: "acknowledged", + step: 2, + step_count: 3, + pass: 1, + pass_count: 2, + steps_sent: 1, + next_step_at: null, + }, } as const const V2AlertIncidentActor = Schema.Struct({ @@ -62,6 +75,40 @@ const V2AlertIncidentActor = Schema.Struct({ }), }).annotate({ identifier: "AlertIncidentActor", title: "Alert incident actor" }) +const Count = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) + +export const V2AlertIncidentEscalation = Schema.Struct({ + chain_id: EscalationChainPublicId.annotate({ + description: + "The escalation chain (`esc_…`) the run walks. It may since have been edited or deleted.", + }), + chain_name: Schema.String.annotate({ + description: "The chain's name when the run started.", + examples: ["Checkout on-call"], + }), + status: AlertEscalationRunStatus.annotate({ + description: + "`active` waits on `next_step_at`; `acknowledged` is paused until the incident is unacknowledged; `resolved`, `exhausted` (every step and repeat sent) and `superseded` (a higher tier started its own chain) have ended.", + examples: ["active"], + }), + step: Count.annotate({ description: "The next step to page, from 1.", examples: [2] }), + step_count: Count.annotate({ description: "Steps in the chain the run started with.", examples: [3] }), + pass: Count.annotate({ description: "The pass the next step belongs to, from 1.", examples: [1] }), + pass_count: Count.annotate({ + description: "Passes over the chain in all: its repeat count plus one.", + examples: [2], + }), + steps_sent: Count.annotate({ description: "Steps paged so far, across passes.", examples: [1] }), + next_step_at: Schema.NullOr(Timestamp).annotate({ + description: "When the next step pages, or `null` while paused or once the run has ended.", + }), +}).annotate({ + identifier: "AlertIncidentEscalation", + title: "Alert incident escalation", + description: "How far the incident's latest escalation chain run has got.", +}) +export type V2AlertIncidentEscalation = Schema.Schema.Type + // v2 wire schemas are annotated `Schema.Struct`s (not `Schema.Class`) — see the // note in api-keys.ts. export const V2AlertIncident = Schema.Struct({ @@ -149,6 +196,10 @@ 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`.", }), + escalation: Schema.NullOr(V2AlertIncidentEscalation).annotate({ + description: + "The incident's latest escalation chain run, or `null` when its severity tier names no chain.", + }), }).annotate({ identifier: "AlertIncident", title: "Alert Incident", diff --git a/packages/domain/src/http/v2/v2-contract.test.ts b/packages/domain/src/http/v2/v2-contract.test.ts index 0d97ae339e..d7b72e3477 100644 --- a/packages/domain/src/http/v2/v2-contract.test.ts +++ b/packages/domain/src/http/v2/v2-contract.test.ts @@ -32,6 +32,7 @@ import { } from "./telemetry" const UUID = "0f8fad5b-d9cb-469f-a165-70867728950e" +const INCIDENT_CHAIN_UUID = "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b" describe("V2PlanetScaleIntegration wire format", () => { it("decodes snake_case fields, ISO timestamps, and a scrp_ public ID", () => { @@ -537,15 +538,47 @@ describe("V2 alerts wire format", () => { acknowledged_by: null, acknowledged_via: null, error_issue_id: null, + escalation: { + chain_id: encodePublicId("esc", INCIDENT_CHAIN_UUID), + chain_name: "Checkout on-call", + status: "active", + step: 2, + step_count: 3, + pass: 1, + pass_count: 2, + steps_sent: 1, + next_step_at: "2026-07-15T09:25:00.000Z", + }, }) expect(incident.id).toBe(INCIDENT_UUID) expect(incident.rule_id).toBe(UUID) + expect(incident.escalation?.chain_id).toBe(INCIDENT_CHAIN_UUID) const wire = Schema.encodeSync(V2AlertIncident)(incident) expect(wire.id).toMatch(/^inc_/) expect(wire.rule_id).toMatch(/^alrt_/) + expect(wire.escalation?.chain_id).toMatch(/^esc_/) expect("ruleId" in wire).toBe(false) }) + + it("refuses an incident escalation with an unknown run status or a negative step", () => { + const decode = Schema.decodeUnknownResult(V2AlertIncident.fields.escalation) + const escalation = { + chain_id: encodePublicId("esc", INCIDENT_CHAIN_UUID), + chain_name: "Checkout on-call", + status: "active", + step: 1, + step_count: 1, + pass: 1, + pass_count: 1, + steps_sent: 0, + next_step_at: null, + } + expect(Result.isSuccess(decode(escalation))).toBe(true) + expect(Result.isSuccess(decode(null))).toBe(true) + expect(Result.isFailure(decode({ ...escalation, status: "paused" }))).toBe(true) + expect(Result.isFailure(decode({ ...escalation, step: -1 }))).toBe(true) + }) }) describe("v2 error envelope", () => { From 16ae3907de667c56b1c4d3dadae22a1db8791fdc Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 13:26:06 +0100 Subject: [PATCH 16/98] fix(alerts): escalation runs survive flapping, stop on de-escalation, and lose to a resolve or ack - the scheduler reads only runs with a decision to make, so standing acks cannot crowd out due steps - a step locks its incident and the drain drops a step whose incident closed or was acknowledged - a flap-suppressed re-open starts the chain past step 1 instead of dropping it - de-escalation stops the critical tier's chain; escalation refreshes a running chain's payload - v2 PATCH tiers without escalation_chain_id keep the stored chain; MCP "none" detaches every tier --- apps/ai/src/mcp/tool-descriptors.generated.ts | 2 +- apps/ai/src/mcp/tools/update-alert-rule.ts | 4 +- apps/api/src/routes/v2/alert-rules.http.ts | 18 +- apps/api/src/routes/v2/alerts.http.test.ts | 7 + .../MapleAPI/Sources/MapleAPI/openapi.json | 2 +- .../alerts/AlertsService.escalation.test.ts | 157 ++++++++++++++++-- .../src/services/alerts/AlertsService.ts | 121 ++++++++++---- .../alerts/EscalationChainsService.ts | 8 +- .../services/alerts/alert-escalation-runs.ts | 109 +++++++++++- packages/domain/src/http/v2/alert-rules.ts | 13 +- .../domain/src/http/v2/v2-contract.test.ts | 9 +- 11 files changed, 383 insertions(+), 67 deletions(-) diff --git a/apps/ai/src/mcp/tool-descriptors.generated.ts b/apps/ai/src/mcp/tool-descriptors.generated.ts index 8676547b41..6004aa6db4 100644 --- a/apps/ai/src/mcp/tool-descriptors.generated.ts +++ b/apps/ai/src/mcp/tool-descriptors.generated.ts @@ -35,7 +35,7 @@ export const MCP_TOOL_DESCRIPTORS: ReadonlyArray = [ {"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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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)."},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of destination_ids, paging step by step until acknowledged (from list_escalation_chains): its UUID or its REST id (esc_\u2026)"},"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"}]},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"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"},"acknowledged":{"type":"boolean"},"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). escalation_chain_id adds step-by-step paging on top.","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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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."},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of destination_ids, paging step by step until acknowledged (from list_escalation_chains): its UUID or its REST id (esc_\u2026)"}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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}}"},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of the destinations (from list_escalation_chains), or \"none\" to detach it. On a rule with warning and critical tiers it sets the critical tier: its UUID or its REST id (esc_\u2026)"}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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":"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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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}}"},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of the destinations (from list_escalation_chains), or \"none\" to detach it from every tier. On a rule with warning and critical tiers an id sets the critical tier: its UUID or its REST id (esc_\u2026)"}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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":"acknowledge_alert_incident","title":"Acknowledge Alert Incident","description":"Mark an open alert incident as acknowledged (someone is on it) or, with acknowledged=false, clear the acknowledgement. While acknowledged, reminders pause; an escalation to a higher severity still notifies and clears it. Only open incidents; ids from list_alert_incidents.","inputSchema":{"type":"object","properties":{"incident_id":{"type":"string","description":"Alert incident ID: its UUID or its REST id (inc_\u2026)"},"acknowledged":{"type":"boolean","description":"false to unacknowledge. Default true."}},"required":["incident_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"ruleId":{"type":"string"},"ruleName":{"type":"string"},"status":{"type":"string"},"acknowledged":{"type":"boolean"},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"anyOf":[{"type":"string"},{"type":"null"}]},"changed":{"type":"boolean"}},"required":["id","ruleId","ruleName","status","acknowledged","acknowledgedAt","acknowledgedBy","changed"],"additionalProperties":true},"annotations":{"title":"Acknowledge Alert Incident","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"list_escalation_chains","title":"List Escalation Chains","description":"List escalation chains: ordered paging steps that notify destinations one step at a time until an incident is acknowledged or resolved. Shows each chain's steps, repeats and the rules using it. Attach one to a rule with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chains":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["chains","total"],"additionalProperties":true},"annotations":{"title":"List Escalation Chains","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, diff --git a/apps/ai/src/mcp/tools/update-alert-rule.ts b/apps/ai/src/mcp/tools/update-alert-rule.ts index 7101584acb..62752b9e28 100644 --- a/apps/ai/src/mcp/tools/update-alert-rule.ts +++ b/apps/ai/src/mcp/tools/update-alert-rule.ts @@ -105,7 +105,7 @@ export const UpdateAlertRuleParameters = Schema.Struct({ ), escalation_chain_id: P.optionalResourceId( "escalationChain", - 'Escalation chain an incident starts on top of the destinations (from list_escalation_chains), or "none" to detach it. On a rule with warning and critical tiers it sets the critical tier', + 'Escalation chain an incident starts on top of the destinations (from list_escalation_chains), or "none" to detach it from every tier. On a rule with warning and critical tiers an id sets the critical tier', ), }) @@ -246,6 +246,8 @@ const withEscalationChain = => { if (chain === undefined) return tiers + // "none" detaches every tier's chain, wherever it was set. + if (chain === null) return Arr.map(tiers, (tier) => ({ ...tier, escalationChainId: null })) const critical = Arr.findLastIndex(tiers, (tier) => tier.severity === "critical") const target = tiers.length === 1 ? 0 : Option.getOrElse(critical, () => tiers.length - 1) return Arr.map(tiers, (tier, index) => (index === target ? { ...tier, escalationChainId: chain } : tier)) diff --git a/apps/api/src/routes/v2/alert-rules.http.ts b/apps/api/src/routes/v2/alert-rules.http.ts index 5b24ec59a0..7e10fa3015 100644 --- a/apps/api/src/routes/v2/alert-rules.http.ts +++ b/apps/api/src/routes/v2/alert-rules.http.ts @@ -282,7 +282,23 @@ const mergeThresholds = ( const fitBound = (tier: AlertThreshold): AlertThreshold => isRangeComparator(comparator) ? tier : { ...tier, upper: null } if (patch.thresholds !== undefined) { - return Effect.succeed(Arr.map(patch.thresholds, fromV2Threshold)) + // A tier that leaves out its chain keeps the one stored at the same severity. + return Effect.succeed( + Arr.map(patch.thresholds, (tier) => + tier.escalation_chain_id !== undefined + ? fromV2Threshold(tier) + : { + ...fromV2Threshold(tier), + escalationChainId: Arr.findFirst( + doc.thresholds, + (stored) => stored.severity === tier.severity, + ).pipe( + Option.flatMapNullishOr((stored) => stored.escalationChainId), + Option.getOrNull, + ), + }, + ), + ) } // Legacy fields echoed back as read (a client PATCHing the document it fetched) are not edits. const echoed = { diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index e5201d1af8..f5a9105fce 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -1381,6 +1381,13 @@ describe("v2 alerts over HTTP", () => { expect(inUse.status).toBe(409) expect(inUse.body.error.code).toBe("escalation_chain_in_use") + // A client that predates chains edits the tiers without the field: the chain stays. + const retuned = await harness.request("PATCH", `/v2/alerts/rules/${rule.body.id}`, key.secret, { + thresholds: [{ severity: "critical", value: 0.06, destination_ids: [primary.body.id] }], + }) + expect(retuned.status, JSON.stringify(retuned.body)).toBe(200) + expect(retuned.body.thresholds[0]).toMatchObject({ value: 0.06, escalation_chain_id: chainId }) + const detached = await harness.request("PATCH", `/v2/alerts/rules/${rule.body.id}`, key.secret, { thresholds: [ { diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index 3431531215..fc159de7e1 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -1421,7 +1421,7 @@ }, "escalation_chain_id": { "$ref": "#/components/schemas/_maple_EscalationChainId", - "description": "Escalation chain (`esc_…`) an incident at this severity starts, on top of `destination_ids`, or `null` (the default) for none. It pages step by step until the incident is acknowledged or resolved." + "description": "Escalation chain (`esc_…`) an incident at this severity starts, on top of `destination_ids`, or `null` for none. It pages step by step until the incident is acknowledged or resolved. Omitted on create means none; omitted on update keeps the tier's current chain." }, "recovery_value": { "description": "The value the signal must cross back over before this tier clears, or `null` (the default) to clear as soon as it stops breaching. Must sit on the healthy side of `value`.", diff --git a/packages/backend/src/services/alerts/AlertsService.escalation.test.ts b/packages/backend/src/services/alerts/AlertsService.escalation.test.ts index 1c5b0b86a9..c1d2d28314 100644 --- a/packages/backend/src/services/alerts/AlertsService.escalation.test.ts +++ b/packages/backend/src/services/alerts/AlertsService.escalation.test.ts @@ -15,7 +15,9 @@ import { afterEach, assert, describe, it } from "@effect/vitest" import { Duration, Effect, Layer, Schema, Array as Arr, String as Str } from "effect" import { TestClock } from "effect/testing" import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@maple/backend/platform/test-pglite" +import { Database } from "@maple/backend/platform/DatabaseLive" import { AlertsService, type AlertsServiceApi } from "./AlertsService" +import { applyEscalationPlan, planEscalationWork, selectEscalationWork } from "./alert-escalation-runs" import { EscalationChainsService } from "./EscalationChainsService" import { DEFAULT_CLOCK_EPOCH_MS, @@ -64,6 +66,7 @@ const layerFor = ( Layer.mergeAll( makeLayer(testDb, makeWarehouseStub(state), { fetch: recordingFetch(requests) }), EscalationChainsService.layer.pipe(Layer.provide(testDb.layer)), + testDb.layer, ) const tick = (alerts: AlertsServiceApi, minutes = 1) => @@ -253,30 +256,162 @@ describe("escalation chains", () => { }).pipe(Effect.provide(layerFor(testDb, state, requests))) }) - it.effect("sends a due step once when two scheduler ticks overlap", () => { + it.effect("moves a step only from the position it was read at, so a second writer loses", () => { const testDb = createTestDb(trackedDbs) const state = { tracesAggregateRows: errorRateRows(10) } const requests: Array = [] return Effect.gen(function* () { yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) - const { alerts, orgId } = yield* setup("org_esc_race") + const { alerts } = yield* setup("org_esc_cas") + const database = yield* Database yield* alerts.runSchedulerTick() yield* tick(alerts) yield* ticks(alerts, 9) yield* TestClock.adjust(Duration.minutes(1)) - yield* Effect.all([alerts.runSchedulerTick(), alerts.runSchedulerTick()], { concurrency: 2 }) - assert.deepStrictEqual( - Arr.filter(requests, (request) => request === "trigger pager"), - ["trigger pager"], + const nowMs = DEFAULT_CLOCK_EPOCH_MS + 11 * 60_000 + // Both writers read the run at the same position, as two overlapping ticks would. + const [row] = yield* database.execute((db) => selectEscalationWork(db, nowMs)) + if (row === undefined) return assert.fail("expected a due run") + const plan = planEscalationWork(row, false, nowMs) + const apply = database.execute((db) => + db.transaction( + applyEscalationPlan(db, { + row, + plan, + nowMs, + enabled: new Set(), + makeDeliveryId: crypto.randomUUID.bind(crypto), + }), + ), ) + assert.deepStrictEqual([yield* apply, yield* apply], ["sent", "lost"]) + }).pipe(Effect.provide(layerFor(testDb, state, requests))) + }) + + it.effect("reads only runs with a decision to make, so standing acks cannot crowd out a due step", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRateRows(10) } + const requests: Array = [] + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const { alerts, orgId } = yield* setup("org_esc_starve") + const database = yield* Database + yield* alerts.runSchedulerTick() + yield* tick(alerts) + yield* setAck(testDb, orgId, true) + // Read once it falls due, and paused there. + yield* ticks(alerts, 12) + assert.strictEqual((yield* runRows(testDb, orgId))[0]?.status, "acknowledged") + // Paused on a standing ack: nothing to decide, however long it has been due. + const nowMs = DEFAULT_CLOCK_EPOCH_MS + 120 * 60_000 + assert.lengthOf(yield* database.execute((db) => selectEscalationWork(db, nowMs, 1)), 0) + yield* setAck(testDb, orgId, false) + assert.lengthOf(yield* database.execute((db) => selectEscalationWork(db, nowMs, 1)), 1) + }).pipe(Effect.provide(layerFor(testDb, state, requests))) + }) + + it.effect("does not send a queued step once the incident is acknowledged", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRateRows(10) } + const requests: Array = [] + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const { alerts, orgId, pager } = yield* setup("org_esc_drain") + yield* alerts.runSchedulerTick() + yield* tick(alerts) + // A step queued just before an ack committed, as the drain would find it. + yield* Effect.promise(() => + executeSql( + testDb, + `insert into alert_delivery_events (id, org_id, incident_id, rule_id, destination_id, delivery_key, + event_type, attempt_number, status, scheduled_at, payload_json, created_at, updated_at) + select gen_random_uuid()::text, org_id, incident_id, rule_id, $2, delivery_key || ':late', event_type, 1, + 'queued', scheduled_at, payload_json || '{"escalation":{"step":2}}'::jsonb, created_at, updated_at + from alert_delivery_events where org_id = $1 limit 1`, + [orgId, pager.id], + ), + ) + yield* setAck(testDb, orgId, true) + yield* tick(alerts) + assert.notInclude(requests, "trigger pager") const rows = yield* Effect.promise(() => - testDb.pglite.query<{ n: number }>( - `select count(*)::int as n from alert_delivery_events d join alert_destinations x on x.id = d.destination_id - where d.org_id = $1 and x.name = 'pager'`, - [orgId], + testDb.pglite.query<{ status: string; error_message: string }>( + "select status, error_message from alert_delivery_events where org_id = $1 and destination_id = $2", + [orgId, pager.id], ), ) - assert.strictEqual(rows.rows[0]?.n, 1) + assert.strictEqual(rows.rows[0]?.status, "failed") + assert.include(rows.rows[0]?.error_message ?? "", "acknowledged") + }).pipe(Effect.provide(layerFor(testDb, state, requests))) + }) + + it.effect("keeps escalating after a flap-suppressed re-open, past the step already paged", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRateRows(10) } + const requests: Array = [] + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const { alerts, orgId } = yield* setup("org_esc_flap") + yield* alerts.runSchedulerTick() + yield* tick(alerts) + // Resolves, then re-opens inside the reminder interval: the re-open pages nobody. + state.tracesAggregateRows = errorRateRows(1) + yield* ticks(alerts, 2) + state.tracesAggregateRows = errorRateRows(10) + yield* ticks(alerts, 2) + assert.sameMembers(requests, ["trigger team", "trigger slack", "resolve team", "resolve slack"]) + // The chain carries on from step 2 for the new incident. + yield* ticks(alerts, 10) + assert.include(requests, "trigger pager") + assert.lengthOf( + Arr.filter(requests, (request) => request === "trigger slack"), + 1, + ) + assert.sameMembers( + Arr.map(yield* runRows(testDb, orgId), (row) => row.status), + ["resolved", "exhausted"], + ) + }).pipe(Effect.provide(layerFor(testDb, state, requests))) + }) + + it.effect("stops the critical tier's chain when the incident drops back to warning", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRateRows(30) } + const requests: Array = [] + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const alerts = yield* AlertsService + const chains = yield* EscalationChainsService + const orgId = asOrgId("org_esc_deescalate") + const userId = asUserId("user_esc_deescalate") + const team = yield* webhook(alerts, orgId, userId, "team") + const pager = yield* webhook(alerts, orgId, userId, "pager") + const chain = yield* chains.create( + orgId, + userId, + adminRoles, + new EscalationChainCreateRequest({ + name: "Page chain", + steps: [{ targets: [{ type: "destination", destinationId: pager.id }], waitMinutes: 10 }], + repeatCount: 3, + }), + ) + yield* alerts.createRule( + orgId, + userId, + adminRoles, + ruleRequest([tier("critical", 20, [], chain.id), tier("warning", 5, [team.id], null)]), + ) + yield* alerts.runSchedulerTick() + yield* tick(alerts) + assert.include(requests, "trigger pager") + state.tracesAggregateRows = errorRateRows(10) + yield* ticks(alerts, 30) + assert.lengthOf( + Arr.filter(requests, (request) => request === "trigger pager"), + 1, + ) + assert.strictEqual((yield* runRows(testDb, orgId))[0]?.status, "superseded") }).pipe(Effect.provide(layerFor(testDb, state, requests))) }) diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index e6294a2ba0..fdb8639156 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -148,8 +148,10 @@ import { applyEscalationPlan, enabledDestinationIds, type EscalationWorkRow, + endEscalationRunsAbove, loadEscalationChain, planEscalationWork, + refreshEscalationRunPayload, selectEscalationWork, startEscalationRun, } from "./alert-escalation-runs" @@ -435,6 +437,8 @@ const StoredDeliveryPayloadSchema = Schema.StructWithRest( }), ), template: Schema.optionalKey(Schema.NullOr(AlertNotificationTemplate)), + /** Set on an escalation step's trigger. */ + escalation: Schema.optionalKey(Schema.Unknown), chart: Schema.optionalKey( Schema.NullOr( Schema.Struct({ @@ -1622,15 +1626,19 @@ export class AlertsService extends Context.Service, ) { - const deliveries = yield* prepareIncidentDeliveries( - orgId, - rule, - incident, - evaluation, - eventType, - scheduledAt, - ) + const deliveries = + prepared ?? + (yield* prepareIncidentDeliveries( + orgId, + rule, + incident, + evaluation, + eventType, + scheduledAt, + )) if (deliveries.length > 0) yield* dbExecute((db) => insertIncidentDeliveries(db, deliveries)) yield* pushIncidentNotification( @@ -2493,6 +2501,24 @@ export class AlertsService extends Context.Service @@ -3451,8 +3494,17 @@ export class AlertsService extends Context.Service [$.id.eq(openIncident.id), $.status.eq("open")]) .returning("id"), ) - if (updated.length === 0 || lifecycle.transition !== "escalated") - return updated + if (updated.length === 0) return updated + // Back down a tier, the higher tier's chain stops paging its on-call. + if (lifecycle.transition === "deescalated") { + yield* endEscalationRunsAbove(db, { + orgId: row.orgId, + incidentId: openIncident.id, + severity, + nowMs: timestamp, + }) + } + if (lifecycle.transition !== "escalated") return updated if (escalation !== null) { yield* startEscalationRun(db, { id: decodeAlertEscalationRunIdSync(makeUuid()), @@ -3461,6 +3513,13 @@ export class AlertsService extends Context.Service - db.execute(Orm.sql`select pg_advisory_xact_lock(hashtext(${orgId}))`) + // The org's advisory lock: rule writes and destination deletion take it around their reference checks. + const lockOrg = lockOrgAlertRules const requireDestinations = (db: MapleDb, orgId: OrgId, steps: EscalationSteps) => Effect.gen(function* () { diff --git a/packages/backend/src/services/alerts/alert-escalation-runs.ts b/packages/backend/src/services/alerts/alert-escalation-runs.ts index 450e941b9a..eeb6e25ff1 100644 --- a/packages/backend/src/services/alerts/alert-escalation-runs.ts +++ b/packages/backend/src/services/alerts/alert-escalation-runs.ts @@ -70,6 +70,8 @@ export interface StartEscalationRunInput { /** The incident's trigger notification; each step resends it to the step's targets. */ readonly payload: Record readonly nowMs: number + /** Start past step 1, which a flap-suppressed re-open's predecessor already paged. */ + readonly firstStepSent?: boolean } /** @@ -79,6 +81,7 @@ export interface StartEscalationRunInput { */ export const startEscalationRun = (db: MapleDb, input: StartEscalationRunInput) => Effect.gen(function* () { + const position = startPosition(input) yield* db.run( PG.update(AlertEscalationRuns) .set({ status: "superseded", endedAt: input.nowMs, updatedAt: input.nowMs }) @@ -88,6 +91,7 @@ export const startEscalationRun = (db: MapleDb, input: StartEscalationRunInput) $.status.in_(...LIVE_STATUSES), ]), ) + if (position === null) return yield* db.run( PG.insertInto(AlertEscalationRuns).values({ id: input.id, @@ -98,9 +102,9 @@ export const startEscalationRun = (db: MapleDb, input: StartEscalationRunInput) severity: input.severity, stepsJson: input.chain.steps, repeatCount: input.chain.repeatCount, - stepIndex: 0, - passIndex: 0, - nextStepAt: input.nowMs, + stepIndex: position.stepIndex, + passIndex: position.passIndex, + nextStepAt: position.nextStepAt, status: "active", payloadJson: input.payload, stepsSent: 0, @@ -112,11 +116,71 @@ export const startEscalationRun = (db: MapleDb, input: StartEscalationRunInput) ) }) +/** Where a new run starts: step 1 now, or the step after it; null when step 1 was the whole chain. */ +const startPosition = (input: StartEscalationRunInput) => { + const first = { stepIndex: 0, passIndex: 0, nextStepAt: input.nowMs } + if (input.firstStepSent !== true) return first + const plan = planEscalationAdvance( + { status: "active", steps: input.chain.steps, repeatCount: input.chain.repeatCount, ...first }, + { open: true, acknowledged: false, silenced: false }, + input.nowMs, + ) + return plan.kind === "send" && plan.next.kind === "step" ? plan.next : null +} + +/** Stops the incident's runs started by a tier above `severity`, on a de-escalation. */ +export const endEscalationRunsAbove = ( + db: MapleDb, + input: { + readonly orgId: OrgId + readonly incidentId: AlertIncidentId + readonly severity: AlertSeverity + readonly nowMs: number + }, +) => + // Two tiers: only a warning incident has a tier above it. + input.severity !== "warning" + ? Effect.void + : db + .run( + PG.update(AlertEscalationRuns) + .set({ status: "superseded", endedAt: input.nowMs, updatedAt: input.nowMs }) + .where(($) => [ + $.orgId.eq(input.orgId), + $.incidentId.eq(input.incidentId), + $.status.in_(...LIVE_STATUSES), + $.severity.eq("critical"), + ]), + ) + .pipe(Effect.asVoid) + +/** The running chain's next steps carry the incident's new trigger (its severity), on an escalation. */ +export const refreshEscalationRunPayload = ( + db: MapleDb, + input: { + readonly orgId: OrgId + readonly incidentId: AlertIncidentId + readonly payload: Record + readonly nowMs: number + }, +) => + db.run( + PG.update(AlertEscalationRuns) + .set({ payloadJson: input.payload, updatedAt: input.nowMs }) + .where(($) => [ + $.orgId.eq(input.orgId), + $.incidentId.eq(input.incidentId), + $.status.in_(...LIVE_STATUSES), + ]), + ) + /** - * Live runs with something to decide: due, paused on an ack that may be gone, or on an incident - * that is no longer open. Across orgs: the scheduler is the system actor. + * Live runs with something to decide: an active run that is due or whose incident closed, and a + * paused run whose ack is gone or whose incident closed. A run paused on a standing ack is not + * read, so long acknowledged incidents cannot crowd out due ones. Across orgs: the scheduler is + * the system actor. */ -export const selectEscalationWork = (db: MapleDb, nowMs: number) => +export const selectEscalationWork = (db: MapleDb, nowMs: number, limit = ESCALATION_RUNS_PER_TICK) => db.run( PG.from(AlertEscalationRuns, "r") .innerJoin(AlertIncidents, "i", (r, i) => r.incidentId.eq(i.id).and(r.orgId.eq(i.orgId))) @@ -142,10 +206,14 @@ export const selectEscalationWork = (db: MapleDb, nowMs: number) => })) .where(($) => [ $.status.in_(...LIVE_STATUSES), - PG.or($.nextStepAt.lte(nowMs), $.status.eq("acknowledged"), $.i.status.neq("open")), + PG.or( + $.i.status.neq("open"), + $.status.eq("active").and($.nextStepAt.lte(nowMs)), + $.status.eq("acknowledged").and($.i.acknowledgedAt.isNull()), + ), ]) .orderBy(["nextStepAt", "asc"]) - .limit(ESCALATION_RUNS_PER_TICK), + .limit(limit), ) export type EscalationWorkRow = Effect.Success>[number] @@ -221,6 +289,20 @@ export const applyEscalationPlan = (db: MapleDb, input: ApplyEscalationPlanInput case "defer": return (yield* write({ status: "active", nextStepAt: plan.nextStepAt })) ? "deferred" : "lost" case "send": { + // Locks the incident so a resolve or ack serializes with this step: once either has + // committed, the step is not sent and the next tick ends or pauses the run. + const firing = yield* db.run( + PG.from(AlertIncidents) + .select("id") + .where(($) => [ + $.id.eq(row.incidentId), + $.orgId.eq(row.orgId), + $.status.eq("open"), + $.acknowledgedAt.isNull(), + ]) + .forUpdate(), + ) + if (firing.length === 0) return "lost" satisfies EscalationApplyOutcome const moved = yield* write( plan.next.kind === "exhausted" ? { @@ -257,6 +339,17 @@ export const applyEscalationPlan = (db: MapleDb, input: ApplyEscalationPlanInput (id) => input.enabled.has(id), ), ) + if (destinationIds.length === 0) { + yield* Effect.logWarning( + "Escalation step has no enabled destination; nobody was notified", + ).pipe( + Effect.annotateLogs({ + runId: row.id, + incidentId: row.incidentId, + step: plan.stepIndex + 1, + }), + ) + } yield* Effect.forEach( destinationIds, (destinationId) => diff --git a/packages/domain/src/http/v2/alert-rules.ts b/packages/domain/src/http/v2/alert-rules.ts index 8b0650879f..558d4b8ab4 100644 --- a/packages/domain/src/http/v2/alert-rules.ts +++ b/packages/domain/src/http/v2/alert-rules.ts @@ -73,12 +73,13 @@ export const V2AlertThreshold = Schema.Struct({ description: "Destinations (`dest_…`) this severity notifies. A `critical` incident also notifies every `warning` destination.", }), - escalation_chain_id: Schema.NullOr(EscalationChainPublicId) - .annotate({ + // Optional rather than defaulted: a PATCH tier without it keeps the stored chain. + escalation_chain_id: Schema.optionalKey( + Schema.NullOr(EscalationChainPublicId).annotate({ description: - "Escalation chain (`esc_…`) an incident at this severity starts, on top of `destination_ids`, or `null` (the default) for none. It pages step by step until the incident is acknowledged or resolved.", - }) - .pipe(Schema.withDecodingDefaultKey(Effect.succeed(null))), + "Escalation chain (`esc_…`) an incident at this severity starts, on top of `destination_ids`, or `null` for none. It pages step by step until the incident is acknowledged or resolved. Omitted on create means none; omitted on update keeps the tier's current chain.", + }), + ), }).annotate({ identifier: "AlertThreshold", title: "Alert threshold", @@ -99,7 +100,7 @@ export const fromV2Threshold = (tier: V2AlertThreshold): AlertThreshold => ({ upper: tier.upper, recoveryValue: tier.recovery_value, destinationIds: tier.destination_ids, - escalationChainId: tier.escalation_chain_id, + escalationChainId: tier.escalation_chain_id ?? null, }) export const toV2Threshold = (tier: AlertThreshold): V2AlertThreshold => ({ diff --git a/packages/domain/src/http/v2/v2-contract.test.ts b/packages/domain/src/http/v2/v2-contract.test.ts index 0d97ae339e..4a5b278bfc 100644 --- a/packages/domain/src/http/v2/v2-contract.test.ts +++ b/packages/domain/src/http/v2/v2-contract.test.ts @@ -3,7 +3,7 @@ import { Effect, Predicate, Result, Schema, Array as Arr, Record as Rec, String import { MapleApiV2 } from "./api" import { V2AlertDestinationCreateParams } from "./alert-destinations" import { V2AlertIncident } from "./alert-incidents" -import { V2AlertRule, V2AlertRuleMutationResponse } from "./alert-rules" +import { fromV2Threshold, V2AlertRule, V2AlertRuleMutationResponse } from "./alert-rules" import { V2ApiKey, V2ApiKeyMutationResponse, V2ApiKeyWithSecret } from "./api-keys" import { V2DashboardMutation } from "./dashboards" import { V2ErrorIssue } from "./error-issues" @@ -420,10 +420,13 @@ describe("V2 alerts wire format", () => { expect("txid" in Schema.encodeSync(V2AlertRule)(base)).toBe(false) }) - it("reads an omitted tier escalation chain as null and round-trips an esc_ id", () => { + it("keeps an omitted tier escalation chain absent, reads it as no chain, and round-trips an esc_ id", () => { const CHAIN_UUID = "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b" const without = Schema.decodeUnknownSync(V2AlertRule)(ruleWire) - expect(without.thresholds[0]?.escalation_chain_id).toBeNull() + // Absent, not null: an update that leaves it out keeps the stored chain. + const [omitted] = without.thresholds + expect(omitted).not.toHaveProperty("escalation_chain_id") + expect(fromV2Threshold(omitted).escalationChainId).toBeNull() const [tier] = ruleWire.thresholds const withChain = Schema.decodeUnknownSync(V2AlertRule)({ From 327bcce82ae8847a2e6e4992f443038ec400ea6f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 13:35:24 +0100 Subject: [PATCH 17/98] feat(alerts): on-call schedules with layers, rotations and overrides Schedules and overrides as org-scoped tables, and OnCallSchedulesService: CRUD, overrides, who is on call at an instant and rendered shifts. Participants and override users must be workspace members; 50 schedules per org, 500 upcoming overrides per schedule, overrides at most 90 days and not over already. --- packages/alerting-core/src/oncall.test.ts | 22 +- packages/alerting-core/src/oncall.ts | 4 +- .../alerts/OnCallSchedulesService.test.ts | 286 + .../services/alerts/OnCallSchedulesService.ts | 605 + .../src/services/org/OrganizationService.ts | 5 + .../migration.sql | 36 + .../snapshot.json | 19751 ++++++++++++++++ packages/db/src/tables/index.ts | 4 + packages/db/src/tables/oncall-schedules.ts | 50 + packages/domain/src/http/oncall-schedules.ts | 175 +- packages/primitives/src/index.ts | 4 + 11 files changed, 20929 insertions(+), 13 deletions(-) create mode 100644 packages/backend/src/services/alerts/OnCallSchedulesService.test.ts create mode 100644 packages/backend/src/services/alerts/OnCallSchedulesService.ts create mode 100644 packages/db/drizzle/20261106121545_oncall_schedules/migration.sql create mode 100644 packages/db/drizzle/20261106121545_oncall_schedules/snapshot.json create mode 100644 packages/db/src/tables/oncall-schedules.ts diff --git a/packages/alerting-core/src/oncall.test.ts b/packages/alerting-core/src/oncall.test.ts index 06bca53fcc..0b244e43e0 100644 --- a/packages/alerting-core/src/oncall.test.ts +++ b/packages/alerting-core/src/oncall.test.ts @@ -4,6 +4,11 @@ import { OnCallOverride, OnCallScheduleConfig } from "@maple/domain/http" import { ONCALL_LOOKAROUND_MS, onCallAt, renderOnCall, type OnCallShift } from "./oncall" const HOUR = 3_600_000 +const DAN_OVERRIDE = "0b7f6a3e-1c2d-4e5f-8a9b-1c2d3e4f5a60" +const EVE_OVERRIDE = "0b7f6a3e-1c2d-4e5f-8a9b-1c2d3e4f5a61" +const O1 = "0b7f6a3e-1c2d-4e5f-8a9b-1c2d3e4f5a62" +const O2 = "0b7f6a3e-1c2d-4e5f-8a9b-1c2d3e4f5a63" +const O3 = "0b7f6a3e-1c2d-4e5f-8a9b-1c2d3e4f5a64" const at = (iso: string) => Date.parse(iso) const decodeConfig = Schema.decodeUnknownSync(OnCallScheduleConfig) const decodeOverrides = Schema.decodeUnknownSync(Schema.Array(OnCallOverride)) @@ -182,14 +187,14 @@ describe("layers and restrictions", () => { describe("overrides", () => { const dan = { - id: "ovr_dan", + id: DAN_OVERRIDE, userId: "dan", startsAt: at("2026-03-04T10:00:00Z"), endsAt: at("2026-03-04T14:00:00Z"), createdAt: 1, } const eve = { - id: "ovr_eve", + id: EVE_OVERRIDE, userId: "eve", startsAt: at("2026-03-04T12:00:00Z"), endsAt: at("2026-03-04T16:00:00Z"), @@ -200,7 +205,7 @@ describe("overrides", () => { const overrides = decodeOverrides([dan]) const covered = onCallAt(weekly, overrides, at("2026-03-04T12:00:00Z")) expect(who(covered)).toBe("dan") - expect(covered?.source).toEqual({ kind: "override", overrideId: "ovr_dan" }) + expect(covered?.source).toEqual({ kind: "override", overrideId: DAN_OVERRIDE }) const render = renderOnCall(weekly, overrides, WEEKLY_ANCHOR, at("2026-03-09T08:00:00Z")) expect(Arr.map(render.final, (s) => ({ user: who(s), ...span(s) }))).toEqual([ @@ -263,21 +268,21 @@ describe("render and onCallAt agree", () => { }) const overrides = decodeOverrides([ { - id: "o1", + id: O1, userId: "gus", startsAt: at("2026-03-20T10:00:00Z"), endsAt: at("2026-03-22T10:00:00Z"), createdAt: 1, }, { - id: "o2", + id: O2, userId: "hal", startsAt: at("2026-03-21T00:00:00Z"), endsAt: at("2026-03-21T06:00:00Z"), createdAt: 2, }, { - id: "o3", + id: O3, userId: "ana", startsAt: at("2026-04-02T00:00:00Z"), endsAt: at("2026-04-03T00:00:00Z"), @@ -312,7 +317,8 @@ describe("render and onCallAt agree", () => { // The sample exercises gaps, every layer and the overrides. const owners = new Set(Arr.map(samples, (t) => who(onCallAt(config, overrides, t)))) expect(owners).toEqual(new Set([null, "ana", "ben", "chen", "dana", "eli", "fay", "gus", "hal"])) - }) + // Thousands of full renders: seconds of CPU, more under a loaded machine. + }, 30_000) }) describe("render caps", () => { @@ -360,7 +366,7 @@ describe("schema", () => { it("rejects an override that ends before it starts", () => { const decodeOverride = Schema.decodeUnknownOption(OnCallOverride) expect( - Option.isNone(decodeOverride({ id: "o", userId: "ana", startsAt: 10, endsAt: 10, createdAt: 0 })), + Option.isNone(decodeOverride({ id: O1, userId: "ana", startsAt: 10, endsAt: 10, createdAt: 0 })), ).toBe(true) }) }) diff --git a/packages/alerting-core/src/oncall.ts b/packages/alerting-core/src/oncall.ts index 5fb025c02a..22c03a89f7 100644 --- a/packages/alerting-core/src/oncall.ts +++ b/packages/alerting-core/src/oncall.ts @@ -7,7 +7,7 @@ import type { OnCallScheduleConfig, OnCallWeekday, } from "@maple/domain/http" -import type { UserId } from "@maple/domain/primitives" +import type { OnCallOverrideId, UserId } from "@maple/domain/primitives" // Pure on-call resolution. Handoffs and restrictions are local wall-clock times in the // schedule's zone. A local time inside a DST gap moves forward by the gap (02:30 becomes @@ -21,7 +21,7 @@ export const MAX_ONCALL_SHIFTS_PER_LAYER = 5_000 export const ONCALL_LOOKAROUND_MS = 15 * 86_400_000 export type OnCallSource = - | { readonly kind: "override"; readonly overrideId: string } + | { readonly kind: "override"; readonly overrideId: OnCallOverrideId } | { readonly kind: "layer"; readonly layerIndex: number } export interface OnCallShift { diff --git a/packages/backend/src/services/alerts/OnCallSchedulesService.test.ts b/packages/backend/src/services/alerts/OnCallSchedulesService.test.ts new file mode 100644 index 0000000000..aa295bbfdc --- /dev/null +++ b/packages/backend/src/services/alerts/OnCallSchedulesService.test.ts @@ -0,0 +1,286 @@ +import { afterEach, assert, describe, it } from "@effect/vitest" +import { + AlertValidationError, + OnCallOverrideCreateRequest, + OnCallScheduleCreateRequest, + OnCallScheduleUpdateRequest, + OrgId, + RoleName, + UserId, +} from "@maple/domain/http" +import { Effect, Exit, Layer, Schema, Array as Arr, String as Str } from "effect" +import { TestClock } from "effect/testing" +import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@maple/backend/platform/test-pglite" +import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" +import { getError, stubOrgMembersService } from "./alerts-service-test-support" +import { OnCallSchedulesService } from "./OnCallSchedulesService" + +const asOrgId = Schema.decodeUnknownSync(OrgId) +const asUserId = Schema.decodeUnknownSync(UserId) +const asRoleName = Schema.decodeUnknownSync(RoleName) + +const ORG = asOrgId("org_oncall") +const OTHER_ORG = asOrgId("org_oncall_other") +const ADMIN = [asRoleName("org:admin")] +const MEMBER = [asRoleName("org:member")] +const ana = asUserId("user_ana") +const ben = asUserId("user_ben") +const chen = asUserId("user_chen") +const at = (iso: string) => Date.parse(iso) + +const createdDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(createdDbs)) + +const makeLayer = (testDb: TestDb) => + Layer.effect(OnCallSchedulesService, OnCallSchedulesService.make).pipe( + Layer.provide( + Layer.merge( + testDb.layer, + Layer.succeed( + OrgMembersService, + stubOrgMembersService([ + { userId: ana, email: "ana@acme.test", name: "Ana Alves", imageUrl: null }, + { userId: ben, email: "ben@acme.test", name: "Ben Brook", imageUrl: null }, + { userId: chen, email: "chen@acme.test", name: null, imageUrl: null }, + ]), + ), + ), + ), + ) + +// Weekly handoff, Monday 09:00 Europe/Berlin; Ana holds the week of Monday 2026-11-09. +const weekly = new OnCallScheduleCreateRequest({ + name: "Checkout primary", + timeZone: "Europe/Berlin", + layers: [ + { + name: "Primary", + participants: [ana, ben, chen], + rotation: { kind: "weekly", handoffWeekday: "mon", handoffTime: "09:00" }, + startsAt: at("2026-11-09T08:00:00Z"), + }, + ], +}) + +// Thursday 2026-11-12 18:00 to Friday 09:00, Berlin time. +const benCoversAna = new OnCallOverrideCreateRequest({ + userId: ben, + startsAt: at("2026-11-12T17:00:00Z"), + endsAt: at("2026-11-13T08:00:00Z"), +}) + +const errorTag = (exit: Exit.Exit) => { + const error = getError(exit) + return typeof error === "object" && error !== null && "_tag" in error ? error._tag : undefined +} + +describe("OnCallSchedulesService", () => { + it.effect("an override takes the shift it covers, and deleting it hands the shift back", () => { + const testDb = createTestDb(createdDbs) + return Effect.gen(function* () { + yield* TestClock.setTime(at("2026-11-10T00:00:00Z")) + const schedules = yield* OnCallSchedulesService + const schedule = yield* schedules.create(ORG, ana, ADMIN, weekly) + assert.strictEqual(schedule.onCallNow?.userId, ana) + + const override = yield* schedules.createOverride(ORG, ana, ADMIN, schedule.id, benCoversAna) + const thursdayEvening = at("2026-11-12T19:00:00Z") + const covered = yield* schedules.onCallAt(ORG, schedule.id, thursdayEvening) + assert.strictEqual(covered.shift?.userId, ben) + assert.deepStrictEqual(covered.shift?.source, { kind: "override", overrideId: override.id }) + assert.strictEqual(covered.shift?.endsAt, "2026-11-13T08:00:00.000Z") + + const listed = yield* schedules.listOverrides(ORG, schedule.id) + assert.deepStrictEqual( + Arr.map(listed.overrides, (o) => o.id), + [override.id], + ) + + yield* schedules.deleteOverride(ORG, ADMIN, schedule.id, override.id) + const back = yield* schedules.onCallAt(ORG, schedule.id, thursdayEvening) + assert.strictEqual(back.shift?.userId, ana) + assert.deepStrictEqual(back.shift?.source, { kind: "layer", layerIndex: 0 }) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("renders the effective shifts with the override cut into the week", () => { + const testDb = createTestDb(createdDbs) + return Effect.gen(function* () { + yield* TestClock.setTime(at("2026-11-10T00:00:00Z")) + const schedules = yield* OnCallSchedulesService + const schedule = yield* schedules.create(ORG, ana, ADMIN, weekly) + yield* schedules.createOverride(ORG, ana, ADMIN, schedule.id, benCoversAna) + + const render = yield* schedules.shifts( + ORG, + schedule.id, + at("2026-11-09T08:00:00Z"), + at("2026-11-23T08:00:00Z"), + ) + assert.deepStrictEqual( + Arr.map(render.final, (s) => [s.userId, s.startsAt, s.endsAt]), + [ + [ana, "2026-11-09T08:00:00.000Z", "2026-11-12T17:00:00.000Z"], + [ben, "2026-11-12T17:00:00.000Z", "2026-11-13T08:00:00.000Z"], + [ana, "2026-11-13T08:00:00.000Z", "2026-11-16T08:00:00.000Z"], + [ben, "2026-11-16T08:00:00.000Z", "2026-11-23T08:00:00.000Z"], + ], + ) + const backwards = yield* Effect.exit( + schedules.shifts(ORG, schedule.id, at("2026-11-23T08:00:00Z"), at("2026-11-09T08:00:00Z")), + ) + assert.strictEqual(errorTag(backwards), "@maple/http/errors/AlertValidationError") + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("rejects participants and override users who are not workspace members", () => { + const testDb = createTestDb(createdDbs) + return Effect.gen(function* () { + yield* TestClock.setTime(at("2026-11-10T00:00:00Z")) + const schedules = yield* OnCallSchedulesService + const stranger = asUserId("user_zed") + const [layer] = weekly.layers + const created = yield* Effect.exit( + schedules.create( + ORG, + ana, + ADMIN, + new OnCallScheduleCreateRequest({ + ...weekly, + layers: [{ ...layer, participants: [ana, stranger] }], + }), + ), + ) + assert.strictEqual(errorTag(created), "@maple/http/errors/AlertValidationError") + const createdError = getError(created) + assert.isTrue( + Schema.is(AlertValidationError)(createdError) && + Str.includes("user_zed")(createdError.message), + ) + + const schedule = yield* schedules.create(ORG, ana, ADMIN, weekly) + const updated = yield* Effect.exit( + schedules.update( + ORG, + ana, + ADMIN, + schedule.id, + new OnCallScheduleUpdateRequest({ layers: [{ ...layer, participants: [stranger] }] }), + ), + ) + assert.strictEqual(errorTag(updated), "@maple/http/errors/AlertValidationError") + const override = yield* Effect.exit( + schedules.createOverride( + ORG, + ana, + ADMIN, + schedule.id, + new OnCallOverrideCreateRequest({ ...benCoversAna, userId: stranger }), + ), + ) + assert.strictEqual(errorTag(override), "@maple/http/errors/AlertValidationError") + assert.strictEqual((yield* schedules.list(ORG)).schedules.length, 1) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("refuses overrides that are backwards, too long or already over", () => { + const testDb = createTestDb(createdDbs) + return Effect.gen(function* () { + yield* TestClock.setTime(at("2026-11-10T00:00:00Z")) + const schedules = yield* OnCallSchedulesService + const schedule = yield* schedules.create(ORG, ana, ADMIN, weekly) + const attempt = (startsAt: string, endsAt: string) => + Effect.exit( + schedules.createOverride( + ORG, + ana, + ADMIN, + schedule.id, + new OnCallOverrideCreateRequest({ + userId: ben, + startsAt: at(startsAt), + endsAt: at(endsAt), + }), + ), + ) + const tags = yield* Effect.all([ + attempt("2026-11-12T10:00:00Z", "2026-11-12T09:00:00Z"), + attempt("2026-11-12T10:00:00Z", "2027-02-12T10:00:00Z"), + attempt("2026-11-01T10:00:00Z", "2026-11-02T10:00:00Z"), + ]).pipe(Effect.map(Arr.map(errorTag))) + assert.deepStrictEqual(tags, [ + "@maple/http/errors/AlertValidationError", + "@maple/http/errors/AlertValidationError", + "@maple/http/errors/AlertValidationError", + ]) + // Started in the past but still running is a valid cover. + const running = yield* attempt("2026-11-09T10:00:00Z", "2026-11-10T10:00:00Z") + assert.isTrue(Exit.isSuccess(running)) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("caps schedules per org and upcoming overrides per schedule", () => { + const testDb = createTestDb(createdDbs) + return Effect.gen(function* () { + yield* TestClock.setTime(at("2026-11-10T00:00:00Z")) + const schedules = yield* OnCallSchedulesService + const schedule = yield* schedules.create(ORG, ana, ADMIN, weekly) + yield* Effect.promise(() => + executeSql( + testDb, + `INSERT INTO oncall_schedules + (id, org_id, name, time_zone, layers_json, created_at, updated_at, created_by, updated_by) + SELECT gen_random_uuid()::text, $1, 'Filler ' || n, 'UTC', layers_json, now(), now(), 'seed', 'seed' + FROM generate_series(1, 49) AS n, oncall_schedules WHERE id = $2`, + [ORG, schedule.id], + ), + ) + const fiftyFirst = yield* Effect.exit(schedules.create(ORG, ana, ADMIN, weekly)) + assert.strictEqual(errorTag(fiftyFirst), "@maple/http/errors/AlertValidationError") + // The cap is per org. + yield* schedules.create(OTHER_ORG, ana, ADMIN, weekly) + + yield* Effect.promise(() => + executeSql( + testDb, + `INSERT INTO oncall_overrides (id, org_id, schedule_id, user_id, starts_at, ends_at, created_by, created_at) + SELECT gen_random_uuid()::text, $1, $2, 'user_ben', + '2026-12-01T00:00:00Z'::timestamptz + n * interval '1 hour', + '2026-12-01T00:30:00Z'::timestamptz + n * interval '1 hour', 'seed', now() + FROM generate_series(1, 500) AS n`, + [ORG, schedule.id], + ), + ) + const overCap = yield* Effect.exit( + schedules.createOverride(ORG, ana, ADMIN, schedule.id, benCoversAna), + ) + assert.strictEqual(errorTag(overCap), "@maple/http/errors/AlertValidationError") + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("deleting a schedule deletes its overrides, and other orgs cannot see it", () => { + const testDb = createTestDb(createdDbs) + return Effect.gen(function* () { + yield* TestClock.setTime(at("2026-11-10T00:00:00Z")) + const schedules = yield* OnCallSchedulesService + const schedule = yield* schedules.create(ORG, ana, ADMIN, weekly) + yield* schedules.createOverride(ORG, ana, ADMIN, schedule.id, benCoversAna) + + const foreign = yield* Effect.exit(schedules.get(OTHER_ORG, schedule.id)) + assert.strictEqual(errorTag(foreign), "@maple/http/errors/OnCallScheduleNotFoundError") + const denied = yield* Effect.exit(schedules.delete(ORG, MEMBER, schedule.id)) + assert.strictEqual(errorTag(denied), "@maple/http/errors/AlertForbiddenError") + + yield* schedules.delete(ORG, ADMIN, schedule.id) + const left = yield* Effect.promise(() => + testDb.pglite.query<{ n: number }>( + `SELECT count(*)::int AS n FROM oncall_overrides WHERE schedule_id = $1`, + [schedule.id], + ), + ) + assert.deepStrictEqual(left.rows, [{ n: 0 }]) + const gone = yield* Effect.exit(schedules.get(ORG, schedule.id)) + assert.strictEqual(errorTag(gone), "@maple/http/errors/OnCallScheduleNotFoundError") + }).pipe(Effect.provide(makeLayer(testDb))) + }) +}) diff --git a/packages/backend/src/services/alerts/OnCallSchedulesService.ts b/packages/backend/src/services/alerts/OnCallSchedulesService.ts new file mode 100644 index 0000000000..083aa1924e --- /dev/null +++ b/packages/backend/src/services/alerts/OnCallSchedulesService.ts @@ -0,0 +1,605 @@ +import { + AlertForbiddenError, + AlertPersistenceError, + AlertValidationError, + IsoDateTimeString, + MAX_ONCALL_FUTURE_OVERRIDES, + MAX_ONCALL_OVERRIDE_MS, + MAX_ONCALL_SCHEDULES_PER_ORG, + OnCallAtResponse, + OnCallNow, + OnCallOverrideDocument, + OnCallOverrideId as OnCallOverrideIdSchema, + OnCallOverrideNotFoundError, + OnCallOverridesListResponse, + OnCallScheduleDocument, + OnCallScheduleId as OnCallScheduleIdSchema, + OnCallScheduleNotFoundError, + OnCallSchedulesListResponse, + OnCallShiftDocument, + OnCallShiftsResponse, + type AlertMemberDirectoryNotConfiguredError, + type AlertMemberDirectoryUnavailableError, + type OnCallLayers, + type OnCallOverride, + type OnCallOverrideCreateRequest, + type OnCallOverrideId, + type OnCallScheduleConfig, + type OnCallScheduleCreateRequest, + type OnCallScheduleId, + type OnCallScheduleUpdateRequest, + type OrgId, + type RoleName, + type UserId, +} from "@maple/domain/http" +import * as PG from "@maple-dev/effect-orm/postgres" +import { + OnCallOverrides, + OnCallSchedules, + type OnCallOverrideRow, + type OnCallScheduleRow, +} from "@maple/db/tables" +import type { MapleDb } from "@maple/db/client" +import { + MAX_ONCALL_RENDER_RANGE_MS, + ONCALL_LOOKAROUND_MS, + onCallAt as resolveOnCallAt, + renderOnCall, + type OnCallShift, +} from "@maple/alerting-core" +import { Array as Arr, Context, DateTime, Effect, Layer, Schema } from "effect" +import { Database } from "@maple/backend/platform/DatabaseLive" +import { makeDbExecute } from "@maple/backend/platform/db-execute" +import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" +import * as Policy from "@maple/domain/policy" +import { lockOrgAlertRules, makePersistenceError } from "./alert-persistence" +import { AlertRuntime } from "./AlertRuntime" + +type MemberLookupError = AlertMemberDirectoryNotConfiguredError | AlertMemberDirectoryUnavailableError +type ScheduleWriteError = + | AlertForbiddenError + | AlertValidationError + | AlertPersistenceError + | MemberLookupError +type ScheduleReadError = AlertPersistenceError | OnCallScheduleNotFoundError + +export interface OnCallSchedulesServiceApi { + readonly list: (orgId: OrgId) => Effect.Effect + readonly get: ( + orgId: OrgId, + scheduleId: OnCallScheduleId, + ) => Effect.Effect + readonly create: ( + orgId: OrgId, + userId: UserId, + roles: ReadonlyArray, + request: OnCallScheduleCreateRequest, + ) => Effect.Effect + readonly update: ( + orgId: OrgId, + userId: UserId, + roles: ReadonlyArray, + scheduleId: OnCallScheduleId, + request: OnCallScheduleUpdateRequest, + ) => Effect.Effect + /** Deletes the schedule's overrides with it. */ + readonly delete: ( + orgId: OrgId, + roles: ReadonlyArray, + scheduleId: OnCallScheduleId, + ) => Effect.Effect + /** Overrides that end after `fromMs` (default now), by start. */ + readonly listOverrides: ( + orgId: OrgId, + scheduleId: OnCallScheduleId, + fromMs?: number, + ) => Effect.Effect + readonly createOverride: ( + orgId: OrgId, + userId: UserId, + roles: ReadonlyArray, + scheduleId: OnCallScheduleId, + request: OnCallOverrideCreateRequest, + ) => Effect.Effect + readonly deleteOverride: ( + orgId: OrgId, + roles: ReadonlyArray, + scheduleId: OnCallScheduleId, + overrideId: OnCallOverrideId, + ) => Effect.Effect + readonly onCallAt: ( + orgId: OrgId, + scheduleId: OnCallScheduleId, + atMs: number, + ) => Effect.Effect + /** Rendered over [fromMs, toMs), clamped to 90 days. */ + readonly shifts: ( + orgId: OrgId, + scheduleId: OnCallScheduleId, + fromMs: number, + toMs: number, + ) => Effect.Effect + /** Display names by user id; empty when the member directory cannot be read. */ + readonly memberNames: (orgId: OrgId) => Effect.Effect> +} + +const validationError = (message: string) => new AlertValidationError({ message, details: [message] }) + +const decodeScheduleIdSync = Schema.decodeUnknownSync(OnCallScheduleIdSchema) +const decodeOverrideIdSync = Schema.decodeUnknownSync(OnCallOverrideIdSchema) +const decodeIsoSync = Schema.decodeUnknownSync(IsoDateTimeString) +const iso = (ms: number) => decodeIsoSync(DateTime.formatIso(DateTime.makeUnsafe(ms))) + +const configOf = (row: Pick): OnCallScheduleConfig => ({ + timeZone: row.timeZone, + layers: row.layersJson, +}) + +const toOverride = (row: OnCallOverrideRow): OnCallOverride => ({ + id: row.id, + userId: row.userId, + startsAt: row.startsAt, + endsAt: row.endsAt, + createdAt: row.createdAt, +}) + +const toShiftDocument = (shift: OnCallShift) => + new OnCallShiftDocument({ + userId: shift.userId, + startsAt: iso(shift.startMs), + endsAt: iso(shift.endMs), + source: shift.source, + }) + +const toOverrideDocument = (row: OnCallOverrideRow) => + new OnCallOverrideDocument({ + id: row.id, + scheduleId: row.scheduleId, + userId: row.userId, + startsAt: iso(row.startsAt), + endsAt: iso(row.endsAt), + createdBy: row.createdBy, + createdAt: iso(row.createdAt), + }) + +const toDocument = (row: OnCallScheduleRow, overrides: ReadonlyArray, nowMs: number) => { + const now = resolveOnCallAt(configOf(row), Arr.map(overrides, toOverride), nowMs) + return new OnCallScheduleDocument({ + id: row.id, + name: row.name, + description: row.description, + timeZone: row.timeZone, + layers: row.layersJson, + onCallNow: + now === null + ? null + : new OnCallNow({ userId: now.userId, startsAt: iso(now.startMs), endsAt: iso(now.endMs) }), + // No escalation chain targets a schedule yet. + chainIds: [], + createdAt: iso(row.createdAt), + updatedAt: iso(row.updatedAt), + }) +} + +const participantsOf = (layers: OnCallLayers): ReadonlyArray => + Arr.dedupe(Arr.flatMap(layers, (layer) => layer.participants)) + +export class OnCallSchedulesService extends Context.Service< + OnCallSchedulesService, + OnCallSchedulesServiceApi +>()("@maple/backend/services/alerts/OnCallSchedulesService", { + make: Effect.gen(function* () { + const database = yield* Database + const runtime = yield* AlertRuntime + const orgMembers = yield* OrgMembersService + const dbExecute = makeDbExecute(database, "OnCallSchedulesService", makePersistenceError) + + const requireManage = (roles: ReadonlyArray) => + Policy.check( + roles, + "alerts:manage", + (denial) => + new AlertForbiddenError({ + message: denial.message, + ...(roles.length > 0 ? { roles: [...roles] } : undefined), + }), + ) + + /** Every id must be a current member of the org. */ + const requireMembers = (orgId: OrgId, userIds: ReadonlyArray) => + orgMembers.resolveMembers(orgId, userIds).pipe( + Effect.catchTag("@maple/http/errors/AlertRecipientSelectionError", (error) => + Effect.fail( + validationError( + `Not members of this workspace: ${Arr.join(error.unknownUserIds, ", ")}`, + ), + ), + ), + Effect.asVoid, + ) + + const annotate = (scheduleId: OnCallScheduleId) => + Effect.annotateCurrentSpan("maple.oncall.schedule_id", scheduleId) + + const findRow = (db: MapleDb, orgId: OrgId, scheduleId: OnCallScheduleId) => + db + .run( + PG.from(OnCallSchedules) + .select() + .where(($) => [$.orgId.eq(orgId), $.id.eq(scheduleId)]) + .limit(1), + ) + .pipe( + Effect.flatMap((rows) => + Arr.match(rows, { + onEmpty: () => + Effect.fail( + new OnCallScheduleNotFoundError({ + message: "On-call schedule not found", + scheduleId, + }), + ), + onNonEmpty: ([row]) => Effect.succeed(row), + }), + ), + ) + + /** Overrides of the org (or one schedule) that overlap [fromMs, toMs); null `toMs` is open-ended. */ + const overlappingOverrides = ( + db: MapleDb, + orgId: OrgId, + fromMs: number, + toMs: number | null, + scheduleId?: OnCallScheduleId, + ) => + db.run( + PG.from(OnCallOverrides) + .select() + .where(($) => [ + $.orgId.eq(orgId), + ...(scheduleId === undefined ? [] : [$.scheduleId.eq(scheduleId)]), + $.endsAt.gt(fromMs), + ...(toMs === null ? [] : [$.startsAt.lt(toMs)]), + ]) + .orderBy(["startsAt", "asc"]) + .limit(MAX_ONCALL_FUTURE_OVERRIDES * 2), + ) + + const nowWindow = (nowMs: number) => + [nowMs - ONCALL_LOOKAROUND_MS, nowMs + ONCALL_LOOKAROUND_MS] as const + + const list = Effect.fn("OnCallSchedulesService.list")(function* (orgId: OrgId) { + const nowMs = yield* runtime.now + const [from, to] = nowWindow(nowMs) + const [rows, overrides] = yield* dbExecute((db) => + Effect.all([ + db.run( + PG.from(OnCallSchedules) + .select() + .where(($) => [$.orgId.eq(orgId)]) + .orderBy(["name", "asc"]) + .limit(MAX_ONCALL_SCHEDULES_PER_ORG), + ), + overlappingOverrides(db, orgId, from, to), + ]), + ) + yield* Effect.annotateCurrentSpan("result.rowCount", rows.length) + return new OnCallSchedulesListResponse({ + schedules: Arr.map(rows, (row) => + toDocument( + row, + Arr.filter(overrides, (o) => o.scheduleId === row.id), + nowMs, + ), + ), + }) + }) + + const loadDocument = (db: MapleDb, orgId: OrgId, scheduleId: OnCallScheduleId, nowMs: number) => { + const [from, to] = nowWindow(nowMs) + return Effect.all([ + findRow(db, orgId, scheduleId), + overlappingOverrides(db, orgId, from, to, scheduleId), + ]).pipe(Effect.map(([row, overrides]) => toDocument(row, overrides, nowMs))) + } + + const get = Effect.fn("OnCallSchedulesService.get")(function* ( + orgId: OrgId, + scheduleId: OnCallScheduleId, + ) { + yield* annotate(scheduleId) + const nowMs = yield* runtime.now + return yield* dbExecute((db) => loadDocument(db, orgId, scheduleId, nowMs)) + }) + + const create = Effect.fn("OnCallSchedulesService.create")(function* ( + orgId: OrgId, + userId: UserId, + roles: ReadonlyArray, + request: OnCallScheduleCreateRequest, + ) { + yield* requireManage(roles) + yield* requireMembers(orgId, participantsOf(request.layers)) + const nowMs = yield* runtime.now + const id = decodeScheduleIdSync(runtime.makeUuid()) + yield* annotate(id) + const row = yield* dbExecute((db) => + db.transaction( + Effect.gen(function* () { + yield* lockOrgAlertRules(db, orgId) + const existing = yield* db.run( + PG.from(OnCallSchedules) + .select("id") + .where(($) => [$.orgId.eq(orgId)]) + .limit(MAX_ONCALL_SCHEDULES_PER_ORG), + ) + if (existing.length >= MAX_ONCALL_SCHEDULES_PER_ORG) { + return yield* validationError( + `Organizations may have at most ${MAX_ONCALL_SCHEDULES_PER_ORG} on-call schedules`, + ) + } + const [inserted] = yield* db.run( + PG.insertInto(OnCallSchedules) + .values({ + id, + orgId, + name: request.name, + description: request.description ?? null, + timeZone: request.timeZone, + layersJson: request.layers, + createdAt: nowMs, + updatedAt: nowMs, + createdBy: userId, + updatedBy: userId, + }) + .returning(), + ) + return inserted + }), + ), + ) + if (row === undefined) { + return yield* new AlertPersistenceError({ message: "On-call schedule was not created" }) + } + return toDocument(row, [], nowMs) + }) + + const update = Effect.fn("OnCallSchedulesService.update")(function* ( + orgId: OrgId, + userId: UserId, + roles: ReadonlyArray, + scheduleId: OnCallScheduleId, + request: OnCallScheduleUpdateRequest, + ) { + yield* annotate(scheduleId) + yield* requireManage(roles) + if (request.layers !== undefined) yield* requireMembers(orgId, participantsOf(request.layers)) + const nowMs = yield* runtime.now + return yield* dbExecute((db) => + db.transaction( + Effect.gen(function* () { + yield* findRow(db, orgId, scheduleId) + yield* db.run( + PG.update(OnCallSchedules) + .set({ + ...(request.name === undefined ? undefined : { name: request.name }), + ...(request.description === undefined + ? undefined + : { description: request.description }), + ...(request.timeZone === undefined + ? undefined + : { timeZone: request.timeZone }), + ...(request.layers === undefined + ? undefined + : { layersJson: request.layers }), + updatedAt: nowMs, + updatedBy: userId, + }) + .where(($) => [$.orgId.eq(orgId), $.id.eq(scheduleId)]), + ) + return yield* loadDocument(db, orgId, scheduleId, nowMs) + }), + ), + ) + }) + + const remove = Effect.fn("OnCallSchedulesService.delete")(function* ( + orgId: OrgId, + roles: ReadonlyArray, + scheduleId: OnCallScheduleId, + ) { + yield* annotate(scheduleId) + yield* requireManage(roles) + yield* dbExecute((db) => + db.transaction( + Effect.gen(function* () { + yield* findRow(db, orgId, scheduleId) + // Overrides go with it through the foreign key's cascade. + yield* db.run( + PG.deleteFrom(OnCallSchedules).where(($) => [ + $.orgId.eq(orgId), + $.id.eq(scheduleId), + ]), + ) + }), + ), + ) + }) + + const listOverrides = Effect.fn("OnCallSchedulesService.listOverrides")(function* ( + orgId: OrgId, + scheduleId: OnCallScheduleId, + fromMs?: number, + ) { + yield* annotate(scheduleId) + const from = fromMs ?? (yield* runtime.now) + const rows = yield* dbExecute((db) => + Effect.gen(function* () { + yield* findRow(db, orgId, scheduleId) + return yield* overlappingOverrides(db, orgId, from, null, scheduleId) + }), + ) + return new OnCallOverridesListResponse({ overrides: Arr.map(rows, toOverrideDocument) }) + }) + + const createOverride = Effect.fn("OnCallSchedulesService.createOverride")(function* ( + orgId: OrgId, + userId: UserId, + roles: ReadonlyArray, + scheduleId: OnCallScheduleId, + request: OnCallOverrideCreateRequest, + ) { + yield* annotate(scheduleId) + yield* requireManage(roles) + const nowMs = yield* runtime.now + if (request.endsAt <= request.startsAt) { + return yield* validationError("An override must end after it starts") + } + if (request.endsAt - request.startsAt > MAX_ONCALL_OVERRIDE_MS) { + return yield* validationError("An override may last at most 90 days") + } + if (request.endsAt <= nowMs) { + return yield* validationError("An override must not end in the past") + } + yield* requireMembers(orgId, [request.userId]) + const id = decodeOverrideIdSync(runtime.makeUuid()) + const row = yield* dbExecute((db) => + db.transaction( + Effect.gen(function* () { + yield* lockOrgAlertRules(db, orgId) + yield* findRow(db, orgId, scheduleId) + const future = yield* db.run( + PG.from(OnCallOverrides) + .select("id") + .where(($) => [ + $.orgId.eq(orgId), + $.scheduleId.eq(scheduleId), + $.endsAt.gt(nowMs), + ]) + .limit(MAX_ONCALL_FUTURE_OVERRIDES), + ) + if (future.length >= MAX_ONCALL_FUTURE_OVERRIDES) { + return yield* validationError( + `A schedule may have at most ${MAX_ONCALL_FUTURE_OVERRIDES} upcoming overrides`, + ) + } + const [inserted] = yield* db.run( + PG.insertInto(OnCallOverrides) + .values({ + id, + orgId, + scheduleId, + userId: request.userId, + startsAt: request.startsAt, + endsAt: request.endsAt, + createdBy: userId, + createdAt: nowMs, + }) + .returning(), + ) + return inserted + }), + ), + ) + if (row === undefined) { + return yield* new AlertPersistenceError({ message: "On-call override was not created" }) + } + return toOverrideDocument(row) + }) + + const deleteOverride = Effect.fn("OnCallSchedulesService.deleteOverride")(function* ( + orgId: OrgId, + roles: ReadonlyArray, + scheduleId: OnCallScheduleId, + overrideId: OnCallOverrideId, + ) { + yield* annotate(scheduleId) + yield* requireManage(roles) + yield* dbExecute((db) => + Effect.gen(function* () { + yield* findRow(db, orgId, scheduleId) + const deleted = yield* db.run( + PG.deleteFrom(OnCallOverrides) + .where(($) => [ + $.orgId.eq(orgId), + $.scheduleId.eq(scheduleId), + $.id.eq(overrideId), + ]) + .returning("id"), + ) + if (deleted.length === 0) { + return yield* new OnCallOverrideNotFoundError({ + message: "On-call override not found", + overrideId, + }) + } + }), + ) + }) + + const onCallAt = Effect.fn("OnCallSchedulesService.onCallAt")(function* ( + orgId: OrgId, + scheduleId: OnCallScheduleId, + atMs: number, + ) { + yield* annotate(scheduleId) + const [from, to] = nowWindow(atMs) + const [row, overrides] = yield* dbExecute((db) => + Effect.all([ + findRow(db, orgId, scheduleId), + overlappingOverrides(db, orgId, from, to, scheduleId), + ]), + ) + const shift = resolveOnCallAt(configOf(row), Arr.map(overrides, toOverride), atMs) + return new OnCallAtResponse({ + at: iso(atMs), + shift: shift === null ? null : toShiftDocument(shift), + }) + }) + + const shifts = Effect.fn("OnCallSchedulesService.shifts")(function* ( + orgId: OrgId, + scheduleId: OnCallScheduleId, + fromMs: number, + toMs: number, + ) { + yield* annotate(scheduleId) + if (toMs <= fromMs) return yield* validationError("The range must end after it starts") + const endMs = Math.min(toMs, fromMs + MAX_ONCALL_RENDER_RANGE_MS) + const [row, overrides] = yield* dbExecute((db) => + Effect.all([ + findRow(db, orgId, scheduleId), + overlappingOverrides(db, orgId, fromMs, endMs, scheduleId), + ]), + ) + const render = renderOnCall(configOf(row), Arr.map(overrides, toOverride), fromMs, endMs) + return new OnCallShiftsResponse({ + from: iso(fromMs), + to: iso(endMs), + layers: Arr.map(render.layers, (layer) => Arr.map(layer, toShiftDocument)), + final: Arr.map(render.final, toShiftDocument), + }) + }) + + const memberNames = Effect.fn("OnCallSchedulesService.memberNames")(function* (orgId: OrgId) { + const members = yield* orgMembers.listMembers(orgId).pipe(Effect.orElseSucceed(() => [])) + return new Map(Arr.map(members, (m) => [m.userId, m.name ?? m.email] as const)) + }) + + return { + memberNames, + list, + get, + create, + update, + delete: remove, + listOverrides, + createOverride, + deleteOverride, + onCallAt, + shifts, + } satisfies OnCallSchedulesServiceApi + }), +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(OrgMembersService.layer)) +} diff --git a/packages/backend/src/services/org/OrganizationService.ts b/packages/backend/src/services/org/OrganizationService.ts index 4b1e3533e7..a68ca4a9d7 100644 --- a/packages/backend/src/services/org/OrganizationService.ts +++ b/packages/backend/src/services/org/OrganizationService.ts @@ -40,6 +40,8 @@ import { ApiKeys, CancellationReviews, EscalationChains, + OnCallOverrides, + OnCallSchedules, ChatIdentities, ChatWorkspaces, CliDeviceAuthorizations, @@ -148,6 +150,9 @@ const ORG_SCOPED_TABLES: ReadonlyArray = [ AlertEscalationRuns, AlertIncidents, EscalationChains, + // Before its schedule: an override row references it. + OnCallOverrides, + OnCallSchedules, AlertRuleStates, AlertRules, // Before its destination: a thread row references it. diff --git a/packages/db/drizzle/20261106121545_oncall_schedules/migration.sql b/packages/db/drizzle/20261106121545_oncall_schedules/migration.sql new file mode 100644 index 0000000000..1201f8112d --- /dev/null +++ b/packages/db/drizzle/20261106121545_oncall_schedules/migration.sql @@ -0,0 +1,36 @@ +-- metadata +CREATE TABLE IF NOT EXISTS "oncall_overrides" ( + "id" text NOT NULL, + "org_id" text NOT NULL, + "schedule_id" text NOT NULL, + "user_id" text NOT NULL, + "starts_at" timestamp with time zone NOT NULL, + "ends_at" timestamp with time zone NOT NULL, + "created_by" text NOT NULL, + "created_at" timestamp with time zone NOT NULL, + CONSTRAINT "oncall_overrides_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +-- metadata +CREATE TABLE IF NOT EXISTS "oncall_schedules" ( + "id" text NOT NULL, + "org_id" text NOT NULL, + "name" text NOT NULL, + "description" text, + "time_zone" text NOT NULL, + "layers_json" jsonb 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 "oncall_schedules_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +-- metadata +CREATE INDEX IF NOT EXISTS "oncall_overrides_org_schedule_ends_idx" ON "oncall_overrides" USING btree ("org_id", "schedule_id", "ends_at"); +--> statement-breakpoint +-- metadata +CREATE INDEX IF NOT EXISTS "oncall_schedules_org_idx" ON "oncall_schedules" USING btree ("org_id"); +--> statement-breakpoint +-- metadata +ALTER TABLE "oncall_overrides" ADD CONSTRAINT "oncall_overrides_schedule_id_oncall_schedules_id_fk" FOREIGN KEY ("schedule_id") REFERENCES "oncall_schedules" ("id") ON DELETE CASCADE; diff --git a/packages/db/drizzle/20261106121545_oncall_schedules/snapshot.json b/packages/db/drizzle/20261106121545_oncall_schedules/snapshot.json new file mode 100644 index 0000000000..b3c51399a2 --- /dev/null +++ b/packages/db/drizzle/20261106121545_oncall_schedules/snapshot.json @@ -0,0 +1,19751 @@ +{ + "version": "1", + "dialect": "postgres", + "id": "745e388d729d6166039805657271a0e44c130e28bae5c84d8d131b9ec0167eff", + "prevIds": [ + "b5de2fc1c65ab81b0c3eb069af5eaeb488195db0ee8089281eb8c0b3248c6048" + ], + "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_escalation_runs", + "primaryKey": { + "name": "alert_escalation_runs_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_incident_events", + "primaryKey": { + "name": "alert_incident_events_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": "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": "escalation_chains", + "primaryKey": { + "name": "escalation_chains_pkey", + "columns": [ + "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": "oncall_overrides", + "primaryKey": { + "name": "oncall_overrides_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "oncall_schedules", + "primaryKey": { + "name": "oncall_schedules_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": "slos", + "primaryKey": { + "name": "slos_pkey", + "columns": [ + "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": "payload_json", + "position": 18, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "created_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "updated_at", + "position": 20, + "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_escalation_runs", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "incident_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "chain_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "chain_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "severity", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "steps_json", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "repeat_count", + "position": 7, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "step_index", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "pass_index", + "position": 9, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "next_step_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "status", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "payload_json", + "position": 12, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "steps_sent", + "position": 13, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "last_step_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "ended_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "created_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "updated_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "incident_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "actor_user_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "actor_name", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "source", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "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": "acknowledged_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "acknowledged_by_user_id", + "position": 25, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "acknowledged_by_name", + "position": 26, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "acknowledged_via", + "position": 27, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "error_issue_id", + "position": 28, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "created_at", + "position": 29, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "updated_at", + "position": 30, + "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": "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": "escalation_chains", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "description", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "steps_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "repeat_count", + "position": 5, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "updated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "created_by", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "updated_by", + "position": 9, + "type": "text", + "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": "oncall_overrides", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "schedule_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "user_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "starts_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "ends_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "created_by", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "description", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "time_zone", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "layers_json", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "updated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "created_by", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "updated_by", + "position": 9, + "type": "text", + "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": "slos", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "description", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "service_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "environments_json", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "sli_json", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "target_percent", + "position": 7, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "window_days", + "position": 8, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "alert_rule_id", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "created_by", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "updated_by", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "created_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "updated_at", + "position": 13, + "type": "timestamp with time zone", + "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_escalation_runs", + "name": "alert_escalation_runs_live_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"next_step_at\"" + ], + "where": "\"status\" in ('active', 'acknowledged')" + }, + { + "kind": "index", + "table": "alert_escalation_runs", + "name": "alert_escalation_runs_live_incident_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"incident_id\"" + ], + "where": "\"status\" IN ('active', 'acknowledged')" + }, + { + "kind": "index", + "table": "alert_escalation_runs", + "name": "alert_escalation_runs_org_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incident_events", + "name": "alert_incident_events_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_id\"", + "\"created_at\"" + ], + "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": "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": "escalation_chains", + "name": "escalation_chains_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "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": "oncall_overrides", + "name": "oncall_overrides_org_schedule_ends_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"schedule_id\"", + "\"ends_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oncall_schedules", + "name": "oncall_schedules_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_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": "slos", + "name": "slos_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "slos", + "name": "slos_org_name_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"name\"" + ], + "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_escalation_runs", + "name": "alert_escalation_runs_incident_id_alert_incidents_id_fk", + "columns": [ + "incident_id" + ], + "foreignTable": "alert_incidents", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "alert_incident_events", + "name": "alert_incident_events_incident_id_alert_incidents_id_fk", + "columns": [ + "incident_id" + ], + "foreignTable": "alert_incidents", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "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": "oncall_overrides", + "name": "oncall_overrides_schedule_id_oncall_schedules_id_fk", + "columns": [ + "schedule_id" + ], + "foreignTable": "oncall_schedules", + "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/index.ts b/packages/db/src/tables/index.ts index 15bc09b3a7..dbbf8214b6 100644 --- a/packages/db/src/tables/index.ts +++ b/packages/db/src/tables/index.ts @@ -42,6 +42,7 @@ import { } from "./errors" import { IssueEscalationPolicies, IssueEscalations } from "./escalations" import { AlertEscalationRuns, EscalationChains } from "./escalation-chains" +import { OnCallOverrides, OnCallSchedules } from "./oncall-schedules" import { AwsConnections } from "./aws-connections" import { GcpConnectors } from "./gcp-connectors" import { GcpResources } from "./gcp-resources" @@ -128,6 +129,7 @@ export * from "./digest" export * from "./errors" export * from "./escalations" export * from "./escalation-chains" +export * from "./oncall-schedules" export * from "./aws-connections" export * from "./gcp-connectors" export * from "./gcp-resources" @@ -204,6 +206,8 @@ export const allTables: ReadonlyArray> = [ IssueEscalations, EscalationChains, AlertEscalationRuns, + OnCallSchedules, + OnCallOverrides, GcpConnectors, GcpResources, AwsConnections, diff --git a/packages/db/src/tables/oncall-schedules.ts b/packages/db/src/tables/oncall-schedules.ts new file mode 100644 index 0000000000..e6179490e9 --- /dev/null +++ b/packages/db/src/tables/oncall-schedules.ts @@ -0,0 +1,50 @@ +import * as PG from "@maple-dev/effect-orm/postgres" +import { OnCallLayers } from "@maple/domain/http" +import { OnCallOverrideId, OnCallScheduleId, OrgId, UserId } from "@maple/domain/primitives" + +/** Who is on call when: layered rotations in one time zone. Overrides sit on top. */ +export const OnCallSchedules = PG.table("oncall_schedules", { + columns: { + id: PG.brand(PG.text, OnCallScheduleId), + orgId: PG.column(PG.brand(PG.text, OrgId), { name: "org_id" }), + name: PG.text, + description: PG.nullable(PG.text), + timeZone: PG.column(PG.text, { name: "time_zone" }), + layersJson: PG.column(PG.jsonb(OnCallLayers), { name: "layers_json" }), + 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("oncall_schedules_org_idx", ["orgId"])], + tenantColumn: "orgId", +}) + +/** One person covering a schedule for a window; the newest overlapping override wins. */ +export const OnCallOverrides = PG.table("oncall_overrides", { + columns: { + id: PG.brand(PG.text, OnCallOverrideId), + orgId: PG.column(PG.brand(PG.text, OrgId), { name: "org_id" }), + scheduleId: PG.column(PG.brand(PG.text, OnCallScheduleId), { name: "schedule_id" }), + userId: PG.column(PG.brand(PG.text, UserId), { name: "user_id" }), + startsAt: PG.column(PG.timestamptzMillis, { name: "starts_at" }), + endsAt: PG.column(PG.timestamptzMillis, { name: "ends_at" }), + createdBy: PG.column(PG.text, { name: "created_by" }), + createdAt: PG.column(PG.timestamptzMillis, { name: "created_at" }), + }, + primaryKey: ["id"], + indexes: [PG.index("oncall_overrides_org_schedule_ends_idx", ["orgId", "scheduleId", "endsAt"])], + foreignKeys: [ + PG.foreignKey({ + columns: ["scheduleId"], + references: OnCallSchedules, + foreignColumns: ["id"], + onDelete: "cascade", + }), + ], + tenantColumn: "orgId", +}) + +export type OnCallScheduleRow = PG.SelectRowOf +export type OnCallOverrideRow = PG.SelectRowOf diff --git a/packages/domain/src/http/oncall-schedules.ts b/packages/domain/src/http/oncall-schedules.ts index 8d61f52f48..78a4ed5fa5 100644 --- a/packages/domain/src/http/oncall-schedules.ts +++ b/packages/domain/src/http/oncall-schedules.ts @@ -1,9 +1,20 @@ import { DateTime, Option, Schema } from "effect" -import { UserId } from "../primitives" +import { + EscalationChainId, + IsoDateTimeString, + OnCallOverrideId, + OnCallScheduleId, + UserId, +} from "../primitives" +import { HttpTaggedError } from "./error-policy" export const MAX_ONCALL_LAYERS = 5 export const MAX_ONCALL_PARTICIPANTS = 50 export const MAX_ONCALL_RESTRICTIONS = 7 +export const MAX_ONCALL_SCHEDULES_PER_ORG = 50 +/** Overrides that have not ended yet, per schedule. */ +export const MAX_ONCALL_FUTURE_OVERRIDES = 500 +export const MAX_ONCALL_OVERRIDE_MS = 90 * 86_400_000 /** A local wall-clock time, "00:00" through "23:59". */ export const OnCallLocalTime = Schema.String.check( @@ -65,16 +76,19 @@ export const OnCallLayer = Schema.Struct({ }) export type OnCallLayer = Schema.Schema.Type +export const OnCallLayers = Schema.NonEmptyArray(OnCallLayer).check(Schema.isMaxLength(MAX_ONCALL_LAYERS)) +export type OnCallLayers = Schema.Schema.Type + /** Later layers sit on top: where a higher layer has someone on call, it wins. */ export const OnCallScheduleConfig = Schema.Struct({ timeZone: OnCallTimeZone, - layers: Schema.NonEmptyArray(OnCallLayer).check(Schema.isMaxLength(MAX_ONCALL_LAYERS)), + layers: OnCallLayers, }) export type OnCallScheduleConfig = Schema.Schema.Type /** Beats every layer; among overlapping overrides the most recently created wins. */ export const OnCallOverride = Schema.Struct({ - id: Schema.String.check(Schema.isMinLength(1)), + id: OnCallOverrideId, userId: UserId, startsAt: EpochMillis, endsAt: EpochMillis, @@ -85,3 +99,158 @@ export const OnCallOverride = Schema.Struct({ }), ) export type OnCallOverride = Schema.Schema.Type + +export const OnCallScheduleName = Schema.String.check( + Schema.isMinLength(1), + Schema.isTrimmed(), + Schema.isMaxLength(120), +) +export const OnCallScheduleDescription = Schema.String.check(Schema.isMaxLength(500)) + +/** Who holds the effective shift at an instant, and its bounds. */ +export class OnCallNow extends Schema.Class("OnCallNow")({ + userId: UserId, + startsAt: IsoDateTimeString, + endsAt: IsoDateTimeString, +}) {} + +export class OnCallScheduleDocument extends Schema.Class("OnCallScheduleDocument")({ + id: OnCallScheduleId, + name: Schema.String, + description: Schema.NullOr(Schema.String), + timeZone: Schema.String, + layers: OnCallLayers, + /** Null when nobody is on call right now. */ + onCallNow: Schema.NullOr(OnCallNow), + /** Escalation chains that page this schedule. */ + chainIds: Schema.Array(EscalationChainId), + createdAt: IsoDateTimeString, + updatedAt: IsoDateTimeString, +}) {} + +export class OnCallSchedulesListResponse extends Schema.Class( + "OnCallSchedulesListResponse", +)({ + schedules: Schema.Array(OnCallScheduleDocument), +}) {} + +export class OnCallScheduleCreateRequest extends Schema.Class( + "OnCallScheduleCreateRequest", +)({ + name: OnCallScheduleName, + description: Schema.optionalKey(Schema.NullOr(OnCallScheduleDescription)), + timeZone: OnCallTimeZone, + layers: OnCallLayers, +}) {} + +export class OnCallScheduleUpdateRequest extends Schema.Class( + "OnCallScheduleUpdateRequest", +)({ + name: Schema.optionalKey(OnCallScheduleName), + description: Schema.optionalKey(Schema.NullOr(OnCallScheduleDescription)), + timeZone: Schema.optionalKey(OnCallTimeZone), + layers: Schema.optionalKey(OnCallLayers), +}) {} + +export class OnCallOverrideDocument extends Schema.Class("OnCallOverrideDocument")({ + id: OnCallOverrideId, + scheduleId: OnCallScheduleId, + userId: UserId, + startsAt: IsoDateTimeString, + endsAt: IsoDateTimeString, + createdBy: Schema.String, + createdAt: IsoDateTimeString, +}) {} + +export class OnCallOverridesListResponse extends Schema.Class( + "OnCallOverridesListResponse", +)({ + overrides: Schema.Array(OnCallOverrideDocument), +}) {} + +/** Times in epoch milliseconds. */ +export class OnCallOverrideCreateRequest extends Schema.Class( + "OnCallOverrideCreateRequest", +)({ + userId: UserId, + startsAt: EpochMillis, + endsAt: EpochMillis, +}) {} + +export const OnCallShiftSource = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("override"), overrideId: OnCallOverrideId }), + Schema.Struct({ kind: Schema.Literal("layer"), layerIndex: Schema.Number }), +]) +export type OnCallShiftSource = Schema.Schema.Type + +export class OnCallShiftDocument extends Schema.Class("OnCallShiftDocument")({ + userId: UserId, + startsAt: IsoDateTimeString, + endsAt: IsoDateTimeString, + source: OnCallShiftSource, +}) {} + +export class OnCallAtResponse extends Schema.Class("OnCallAtResponse")({ + at: IsoDateTimeString, + /** Null is a gap: nobody is on call. */ + shift: Schema.NullOr(OnCallShiftDocument), +}) {} + +export class OnCallShiftsResponse extends Schema.Class("OnCallShiftsResponse")({ + from: IsoDateTimeString, + /** The range end after the 90-day render cap. */ + to: IsoDateTimeString, + /** Each layer's own shifts after its restrictions. */ + layers: Schema.Array(Schema.Array(OnCallShiftDocument)), + /** The effective schedule after layering and overrides. */ + final: Schema.Array(OnCallShiftDocument), +}) {} + +export class OnCallScheduleNotFoundError extends HttpTaggedError()( + "@maple/http/errors/OnCallScheduleNotFoundError", + { message: Schema.String, scheduleId: OnCallScheduleId }, + { + status: 404, + code: "oncall_schedule_not_found", + title: "On-call schedule not found", + message: "No such on-call schedule.", + param: "id", + retry: "never", + recovery: "none", + exposure: "redacted", + }, +) {} + +export class OnCallOverrideNotFoundError extends HttpTaggedError()( + "@maple/http/errors/OnCallOverrideNotFoundError", + { message: Schema.String, overrideId: OnCallOverrideId }, + { + status: 404, + code: "oncall_override_not_found", + title: "On-call override not found", + message: "No such on-call override.", + param: "override_id", + retry: "never", + recovery: "none", + exposure: "redacted", + }, +) {} + +export class OnCallScheduleInUseError extends HttpTaggedError()( + "@maple/http/errors/OnCallScheduleInUseError", + { + message: Schema.String, + scheduleId: OnCallScheduleId, + chainIds: Schema.Array(EscalationChainId), + chainNames: Schema.Array(Schema.String), + }, + { + status: 409, + code: "oncall_schedule_in_use", + title: "On-call schedule is in use", + message: "The on-call schedule is paged by one or more escalation chains.", + retry: "never", + recovery: "fix_request", + exposure: "redacted", + }, +) {} diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index aa9701bed2..d63ac61aa2 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -202,6 +202,10 @@ export const EscalationChainId = MapleUuidId("@maple/EscalationChainId", "Escala export type EscalationChainId = Schema.Schema.Type export const AlertEscalationRunId = MapleUuidId("@maple/AlertEscalationRunId", "Alert Escalation Run ID") export type AlertEscalationRunId = Schema.Schema.Type +export const OnCallScheduleId = MapleUuidId("@maple/OnCallScheduleId", "On-Call Schedule ID") +export type OnCallScheduleId = Schema.Schema.Type +export const OnCallOverrideId = MapleUuidId("@maple/OnCallOverrideId", "On-Call Override ID") +export type OnCallOverrideId = Schema.Schema.Type export const MobileDeviceId = MapleUuidId("@maple/MobileDeviceId", "Mobile Device ID") export type MobileDeviceId = Schema.Schema.Type From 5a8f01658fa3db9fad0767d16424d162d951c021 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 13:35:34 +0100 Subject: [PATCH 18/98] feat(alerts): on-call schedules in the v2 API and MCP /v2/alerts/oncall_schedules CRUD with onc_ ids, who is on call at an instant, rendered shifts and ovr_ overrides, audited. MCP tools list_oncall_schedules, who_is_on_call, create/update/delete_oncall_schedule and create/delete_oncall_override, naming people by display name. --- apps/ai/src/mcp/dispatcher.ts | 2 + apps/ai/src/mcp/lib/oncall-schedules.ts | 251 +++++++ apps/ai/src/mcp/lib/params.ts | 12 + apps/ai/src/mcp/resources/instructions.ts | 1 + apps/ai/src/mcp/tool-descriptors.generated.ts | 7 + .../__tests__/oncall-schedule-tools.test.ts | 371 ++++++++++ .../src/mcp/tools/create-oncall-override.ts | 92 +++ .../src/mcp/tools/create-oncall-schedule.ts | 66 ++ .../src/mcp/tools/delete-oncall-override.ts | 52 ++ .../src/mcp/tools/delete-oncall-schedule.ts | 50 ++ .../ai/src/mcp/tools/list-oncall-schedules.ts | 64 ++ apps/ai/src/mcp/tools/mutating.ts | 5 + apps/ai/src/mcp/tools/registry.ts | 14 + apps/ai/src/mcp/tools/runtime-requirements.ts | 2 + .../src/mcp/tools/update-oncall-schedule.ts | 79 ++ apps/ai/src/mcp/tools/who-is-on-call.ts | 91 +++ .../test/mcp/unimplemented-tool-services.ts | 2 + apps/api/src/routes/v2/alerts.http.test.ts | 188 +++++ .../src/routes/v2/oncall-schedules.http.ts | 260 +++++++ apps/api/src/routes/v2/v2-test-support.ts | 10 + apps/api/src/runtime/http-graph.ts | 2 + apps/api/src/runtime/service-graph.ts | 2 + .../MapleAPI/Sources/MapleAPI/openapi.json | 4 + .../landing/src/content/docs/reference/mcp.md | 7 + .../conversation/kit/tool-metadata.ts | 14 + packages/domain/src/audit-actions.ts | 15 + .../anticipated-error-identifiers.ts | 3 + packages/domain/src/http/v2/api.ts | 2 + packages/domain/src/http/v2/index.ts | 1 + .../domain/src/http/v2/oncall-schedules.ts | 676 ++++++++++++++++++ packages/domain/src/http/v2/openapi.test.ts | 10 + packages/domain/src/http/v2/public-id.ts | 2 + packages/domain/src/http/v2/resource-ids.ts | 4 + .../domain/src/http/v2/v2-contract.test.ts | 71 ++ packages/domain/src/mcp-outputs/catalog.ts | 15 + packages/domain/src/mcp-outputs/index.ts | 1 + .../src/mcp-outputs/oncall-schedules.ts | 88 +++ 37 files changed, 2536 insertions(+) create mode 100644 apps/ai/src/mcp/lib/oncall-schedules.ts create mode 100644 apps/ai/src/mcp/tools/__tests__/oncall-schedule-tools.test.ts create mode 100644 apps/ai/src/mcp/tools/create-oncall-override.ts create mode 100644 apps/ai/src/mcp/tools/create-oncall-schedule.ts create mode 100644 apps/ai/src/mcp/tools/delete-oncall-override.ts create mode 100644 apps/ai/src/mcp/tools/delete-oncall-schedule.ts create mode 100644 apps/ai/src/mcp/tools/list-oncall-schedules.ts create mode 100644 apps/ai/src/mcp/tools/update-oncall-schedule.ts create mode 100644 apps/ai/src/mcp/tools/who-is-on-call.ts create mode 100644 apps/api/src/routes/v2/oncall-schedules.http.ts create mode 100644 packages/domain/src/http/v2/oncall-schedules.ts create mode 100644 packages/domain/src/mcp-outputs/oncall-schedules.ts diff --git a/apps/ai/src/mcp/dispatcher.ts b/apps/ai/src/mcp/dispatcher.ts index af67377c9c..c62b7ed876 100644 --- a/apps/ai/src/mcp/dispatcher.ts +++ b/apps/ai/src/mcp/dispatcher.ts @@ -13,6 +13,7 @@ import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertRead import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" +import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" import { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService" import { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService" @@ -275,6 +276,7 @@ const McpRuntimeServicesLive = Layer.mergeAll( AlertIncidentAckService.layer, AlertRulesService.layer, EscalationChainsService.layer, + OnCallSchedulesService.layer, AlertsService.layer, AuditLogService.layer, DashboardPersistenceService.layer, diff --git a/apps/ai/src/mcp/lib/oncall-schedules.ts b/apps/ai/src/mcp/lib/oncall-schedules.ts new file mode 100644 index 0000000000..961d24fcdc --- /dev/null +++ b/apps/ai/src/mcp/lib/oncall-schedules.ts @@ -0,0 +1,251 @@ +/** Pieces the on-call schedule MCP tools share: rows, rotation text, schedule lookup and error mapping. */ +import { Array as Arr, DateTime, Effect, Option, Schema, String as Str } from "effect" +import type { + AlertForbiddenError, + AlertMemberDirectoryNotConfiguredError, + AlertMemberDirectoryUnavailableError, + AlertPersistenceError, + AlertValidationError, + OnCallLayer, + OnCallOverrideDocument, + OnCallOverrideNotFoundError, + OnCallRestriction, + OnCallRotation, + OnCallScheduleDocument, + OnCallScheduleNotFoundError, + OnCallShiftDocument, + OrgId, +} from "@maple/domain/http" +import { OnCallScheduleId } from "@maple/domain/http" +import { encodePublicId, PublicIdPrefixes, V2OnCallLayers, fromV2OnCallLayer } from "@maple/domain/http/v2" +import type { + OnCallOverrideRow, + OnCallPersonRow, + OnCallScheduleRow, + OnCallShiftRow, +} from "@maple/domain/mcp-outputs" +import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" +import { McpInvalidInputError } from "../tools/types" +import { toMcpHttpError } from "./map-http-error" +import * as P from "./params" +import { doc } from "./tool-doc" + +type ScheduleRow = typeof OnCallScheduleRow.Type +type ShiftRow = typeof OnCallShiftRow.Type +type PersonRow = typeof OnCallPersonRow.Type +export type MemberNames = ReadonlyMap + +/** The layers a tool takes, in the REST shape. */ +export const LayersInput = V2OnCallLayers + +export const layersParameterDescription = + 'Layers, bottom first; later layers win where they have someone on call. Each: {"name","participants":["user_…"],"rotation":{"kind":"weekly","handoff_weekday":"mon","handoff_time":"09:00"} | {"kind":"daily","handoff_time":"09:00"} | {"kind":"custom","length_hours":12},"starts_at":"2026-11-09T08:00:00Z" (participant 0 holds the shift containing it),"restrictions":[{"weekdays":["mon"],"start_time":"18:00","end_time":"08:00"}] (optional)}. Participants are Maple user ids of workspace members. At most 5 layers' + +export const toLayers = (layers: typeof LayersInput.Type) => Arr.map(layers, fromV2OnCallLayer) + +const WEEKDAY_NAMES = { + mon: "Monday", + tue: "Tuesday", + wed: "Wednesday", + thu: "Thursday", + fri: "Friday", + sat: "Saturday", + sun: "Sunday", +} as const + +export const formatRotation = (rotation: OnCallRotation): string => { + switch (rotation.kind) { + case "daily": + return `daily at ${rotation.handoffTime}` + case "weekly": + return `weekly, ${WEEKDAY_NAMES[rotation.handoffWeekday]} ${rotation.handoffTime}` + case "custom": + return `every ${rotation.lengthHours}h` + } +} + +const formatRestriction = (r: OnCallRestriction): string => + `${Arr.join(r.weekdays, ",")} ${r.startTime}-${r.endTime}` + +const iso = (ms: number) => DateTime.formatIso(DateTime.makeUnsafe(ms)) +export const isoMs = (value: string): number => DateTime.toEpochMillis(DateTime.makeUnsafe(value)) + +export const person = (names: MemberNames, userId: string): PersonRow => ({ + userId, + name: names.get(userId) ?? null, +}) + +export const personLabel = (p: PersonRow): string => (p.name === null ? p.userId : `${p.name} (${p.userId})`) + +const layerRow = (names: MemberNames) => (layer: OnCallLayer) => ({ + name: layer.name, + participants: Arr.map(layer.participants, (id) => person(names, id)), + rotation: formatRotation(layer.rotation), + startsAt: iso(layer.startsAt), + restrictions: Arr.map(layer.restrictions ?? [], formatRestriction), +}) + +export const toShiftRow = ( + shift: OnCallShiftDocument, + layers: ReadonlyArray, + names: MemberNames, +): ShiftRow => ({ + user: person(names, shift.userId), + startsAt: shift.startsAt, + endsAt: shift.endsAt, + source: shift.source.kind, + layerName: shift.source.kind === "layer" ? (layers[shift.source.layerIndex]?.name ?? null) : null, + overrideId: shift.source.kind === "override" ? shift.source.overrideId : null, +}) + +export const toScheduleRow = (schedule: OnCallScheduleDocument, names: MemberNames): ScheduleRow => ({ + id: schedule.id, + publicId: encodePublicId(PublicIdPrefixes.oncallSchedule, schedule.id), + name: schedule.name, + description: schedule.description, + timeZone: schedule.timeZone, + layers: Arr.map(schedule.layers, layerRow(names)), + onCallNow: + schedule.onCallNow === null + ? null + : { + user: person(names, schedule.onCallNow.userId), + startsAt: schedule.onCallNow.startsAt, + endsAt: schedule.onCallNow.endsAt, + }, + chainIds: [...schedule.chainIds], + createdAt: schedule.createdAt, + updatedAt: schedule.updatedAt, +}) + +export const toOverrideRow = ( + override: OnCallOverrideDocument, + names: MemberNames, +): typeof OnCallOverrideRow.Type => ({ + id: override.id, + publicId: encodePublicId(PublicIdPrefixes.oncallOverride, override.id), + scheduleId: override.scheduleId, + user: person(names, override.userId), + startsAt: override.startsAt, + endsAt: override.endsAt, +}) + +/** e.g. `Ana Alves (user_ana) until 2026-11-16T08:00:00.000Z`, or that nobody is. */ +export const formatOnCallNow = (schedule: ScheduleRow): string => + schedule.onCallNow === null + ? "nobody on call right now" + : `${personLabel(schedule.onCallNow.user)} on call until ${schedule.onCallNow.endsAt}` + +/** One line per layer, e.g. `Primary: weekly, Monday 09:00 · Ana Alves, Ben Brook`. */ +export const formatLayers = (schedule: ScheduleRow): string => + Arr.join( + Arr.map(schedule.layers, (layer, index) => { + const people = Arr.join( + Arr.map(layer.participants, (p) => p.name ?? p.userId), + ", ", + ) + const windows = + layer.restrictions.length === 0 ? "" : ` (only ${Arr.join(layer.restrictions, "; ")})` + return `${index + 1}. ${layer.name}: ${layer.rotation}${windows}, from ${layer.startsAt} · ${people}` + }), + "\n", + ) + +export const scheduleBlocks = (schedule: ScheduleRow) => [ + doc.fields([ + ["ID", schedule.id], + ["REST id", schedule.publicId], + ["Name", schedule.name], + ["Description", schedule.description ?? undefined], + ["Time zone", schedule.timeZone], + ["On call now", formatOnCallNow(schedule)], + ]), + doc.text(formatLayers(schedule)), +] + +export const scheduleNotFound = (raw: string, parameter = "schedule_id") => + new McpInvalidInputError({ + message: `On-call schedule not found: ${raw}. Use list_oncall_schedules to find schedule IDs.`, + parameter, + }) + +const failWith = + (pipeName: string) => + ( + error: + | AlertForbiddenError + | AlertPersistenceError + | AlertMemberDirectoryNotConfiguredError + | AlertMemberDirectoryUnavailableError, + ) => + Effect.fail(toMcpHttpError(pipeName)(error)) + +const invalid = (parameter: string) => (error: AlertValidationError) => + Effect.fail(new McpInvalidInputError({ message: `Invalid on-call request: ${error.message}`, parameter })) + +const notFound = (error: OnCallScheduleNotFoundError) => Effect.fail(scheduleNotFound(error.scheduleId)) + +const writeErrors = (pipeName: string, parameter: string) => ({ + "@maple/http/errors/AlertValidationError": invalid(parameter), + "@maple/http/errors/AlertForbiddenError": failWith(pipeName), + "@maple/http/errors/AlertPersistenceError": failWith(pipeName), + "@maple/http/errors/AlertMemberDirectoryNotConfiguredError": failWith(pipeName), + "@maple/http/errors/AlertMemberDirectoryUnavailableError": failWith(pipeName), +}) + +export const createScheduleErrors = writeErrors("create_oncall_schedule", "layers") +export const updateScheduleErrors = { + ...writeErrors("update_oncall_schedule", "layers"), + "@maple/http/errors/OnCallScheduleNotFoundError": notFound, +} +export const createOverrideErrors = { + ...writeErrors("create_oncall_override", "user_id"), + "@maple/http/errors/OnCallScheduleNotFoundError": notFound, +} +export const deleteScheduleErrors = { + "@maple/http/errors/AlertForbiddenError": failWith("delete_oncall_schedule"), + "@maple/http/errors/AlertPersistenceError": failWith("delete_oncall_schedule"), + "@maple/http/errors/OnCallScheduleNotFoundError": notFound, +} +export const deleteOverrideErrors = { + "@maple/http/errors/AlertForbiddenError": failWith("delete_oncall_override"), + "@maple/http/errors/AlertPersistenceError": failWith("delete_oncall_override"), + "@maple/http/errors/OnCallScheduleNotFoundError": notFound, + "@maple/http/errors/OnCallOverrideNotFoundError": (error: OnCallOverrideNotFoundError) => + Effect.fail( + new McpInvalidInputError({ + message: `On-call override not found: ${error.overrideId}.`, + parameter: "override_id", + }), + ), +} + +const isScheduleId = Schema.is(OnCallScheduleId) + +/** A schedule by its UUID, its REST id or its name (case-insensitive). */ +export const resolveSchedule = (orgId: OrgId, raw: string, parameter: string) => + Effect.gen(function* () { + const schedules = yield* OnCallSchedulesService + const id = Option.filter(P.internalIdOf("oncallSchedule", raw), isScheduleId) + if (Option.isSome(id)) { + return yield* schedules.get(orgId, id.value).pipe( + Effect.catchTags({ + "@maple/http/errors/OnCallScheduleNotFoundError": () => + Effect.fail(scheduleNotFound(raw, parameter)), + "@maple/http/errors/AlertPersistenceError": failWith("who_is_on_call"), + }), + ) + } + const listed = yield* schedules.list(orgId).pipe(Effect.mapError(toMcpHttpError("who_is_on_call"))) + const wanted = Str.toLowerCase(Str.trim(raw)) + return yield* Effect.fromOption( + Arr.findFirst(listed.schedules, (s) => Str.toLowerCase(s.name) === wanted), + ).pipe(Effect.mapError(() => scheduleNotFound(raw, parameter))) + }) + +/** Member display names; the service answers empty when the directory is unavailable. */ +export const memberNames = (orgId: OrgId) => + Effect.gen(function* () { + const schedules = yield* OnCallSchedulesService + return yield* schedules.memberNames(orgId) + }) diff --git a/apps/ai/src/mcp/lib/params.ts b/apps/ai/src/mcp/lib/params.ts index 6d5b8cefe6..18b6f8f07b 100644 --- a/apps/ai/src/mcp/lib/params.ts +++ b/apps/ai/src/mcp/lib/params.ts @@ -25,6 +25,8 @@ import { DashboardId, ErrorIssueId, EscalationChainId, + OnCallOverrideId, + OnCallScheduleId, } from "@maple/domain/primitives" import { WarehouseTimeInput, type WarehouseDateTime } from "@maple/query-engine" import { McpInvalidInputError } from "../tools/types" @@ -163,6 +165,16 @@ const RESOURCE_IDS = { schema: EscalationChainId, noun: "escalation chain", }, + oncallSchedule: { + prefix: PublicIdPrefixes.oncallSchedule, + schema: OnCallScheduleId, + noun: "on-call schedule", + }, + oncallOverride: { + prefix: PublicIdPrefixes.oncallOverride, + schema: OnCallOverrideId, + noun: "on-call override", + }, 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 27e51035fe..2e4b233ae3 100644 --- a/apps/ai/src/mcp/resources/instructions.ts +++ b/apps/ai/src/mcp/resources/instructions.ts @@ -118,6 +118,7 @@ first occurrences ──> (candidate) ──> issue in \`triage\` - 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 - Paging step by step until someone acknowledges: list_escalation_chains / create_escalation_chain (steps of destinations + wait_minutes) / update_escalation_chain / delete_escalation_chain; attach to a rule with create_alert_rule / update_alert_rule escalation_chain_id. The rule's own destinations still fire at once. +- Who is on call when: list_oncall_schedules / who_is_on_call (schedule id or name, optional at) / create_oncall_schedule (layers of workspace members rotating daily, weekly or every N hours) / update_oncall_schedule / delete_oncall_schedule; cover a window with create_oncall_override / delete_oncall_override. - Uptime: list_uptime_monitors is every http/tcp/dns check and heartbeat with its status and last check; a failed check's trace id goes to inspect_trace. Admins manage them with create_uptime_monitor / update_uptime_monitor (enabled=false pauses) / delete_uptime_monitor. A down monitor opens an alert incident with signal type \`uptime\`; its rule is managed only through the monitor. - SLOs: list_slos is every SLO with its SLI, budget left and worst burn rate, burning ones first -> inspect_slo (each 5m/30m/1h/6h burn window, which tier breaches, alerting) -> get_incident_timeline rule_id=. Admins and engineers manage them with create_slo / update_slo (alerting_enabled=false silences it) / delete_slo. An SLO's burn-rate rule (signal type \`slo_burn_rate\`) is managed only through the SLO. - Status pages (what customers see, separate from alert incidents): list_status_pages -> get_status_page (component statuses, open incidents, freshness) -> create_status_page_incident / schedule_status_page_maintenance (drafts unless publish=true) -> post_status_page_update -> resolve_status_page_incident. Every message is public text. Admins edit a whole page (settings, theme, components) with get_status_page_config -> apply_status_page_config, one atomic write. diff --git a/apps/ai/src/mcp/tool-descriptors.generated.ts b/apps/ai/src/mcp/tool-descriptors.generated.ts index 8676547b41..ea3c18e802 100644 --- a/apps/ai/src/mcp/tool-descriptors.generated.ts +++ b/apps/ai/src/mcp/tool-descriptors.generated.ts @@ -42,6 +42,13 @@ export const MCP_TOOL_DESCRIPTORS: ReadonlyArray = [ {"name":"create_escalation_chain","title":"Create Escalation Chain","description":"Create an escalation chain: steps that each notify their destinations, then wait for an acknowledgement before the next step. A rule tier that names the chain still notifies its own destinations at once; the chain pages on top until the incident is acknowledged or resolved. Attach it with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"Chain name, e.g. 'Checkout on-call'"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["destination_ids","wait_minutes"],"additionalProperties":false}},"description":"Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":10},{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. At most 10 steps of 10 destinations"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5 (default 0)"},"description":{"type":"string","description":"Free-form notes"}},"required":["name","steps"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Create Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, {"name":"update_escalation_chain","title":"Update Escalation Chain","description":"Change an escalation chain's name, steps, repeats or description. Pass only what changes; steps replaces every step. Escalations already running finish on the steps they started with. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to change: its UUID or its REST id (esc_\u2026)"},"name":{"type":"string","description":"New name"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["destination_ids","wait_minutes"],"additionalProperties":false}},"description":"Replaces every step. Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":10},{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. At most 10 steps of 10 destinations"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5"},"description":{"type":"string","description":"New notes"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Update Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"delete_escalation_chain","title":"Delete Escalation Chain","description":"Delete an escalation chain. Refused while a rule tier still names it: detach it with update_alert_rule escalation_chain_id=\"none\" first. Escalations already running finish. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to delete: its UUID or its REST id (esc_\u2026)"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"additionalProperties":true},"annotations":{"title":"Delete Escalation Chain","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}}, + {"name":"list_oncall_schedules","title":"List On-Call Schedules","description":"List on-call schedules: layered rotations in a time zone that say who is on call when, with overrides on top. Shows each schedule's layers, participants and who is on call right now. Ask about one instant with who_is_on_call.","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"schedules":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"timeZone":{"type":"string"},"layers":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"participants":{"type":"array","items":{"type":"object","properties":{"userId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["userId","name"],"additionalProperties":true}},"rotation":{"type":"string"},"startsAt":{"type":"string"},"restrictions":{"type":"array","items":{"type":"string"}}},"required":["name","participants","rotation","startsAt","restrictions"],"additionalProperties":true}},"onCallNow":{"anyOf":[{"type":"object","properties":{"user":{"type":"object","properties":{"userId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["userId","name"],"additionalProperties":true},"startsAt":{"type":"string"},"endsAt":{"type":"string"}},"required":["user","startsAt","endsAt"],"additionalProperties":true},{"type":"null"}]},"chainIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","timeZone","layers","onCallNow","chainIds","createdAt","updatedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["schedules","total"],"additionalProperties":true},"annotations":{"title":"List On-Call Schedules","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"who_is_on_call","title":"Who Is On Call","description":"Who is on call for a schedule at an instant (default now), what put them there (a layer or an override), when their shift ends and who is next. Schedules from list_oncall_schedules, by id or name.","inputSchema":{"type":"object","properties":{"schedule":{"type":"string","description":"On-call schedule: its UUID, its REST id (onc_\u2026) or its name"},"at":{"type":"string","description":"The instant to ask about (default now), UTC (YYYY-MM-DD HH:mm:ss or ISO 8601)"}},"required":["schedule"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"scheduleId":{"type":"string"},"scheduleName":{"type":"string"},"timeZone":{"type":"string"},"at":{"type":"string"},"onCall":{"anyOf":[{"type":"object","properties":{"user":{"type":"object","properties":{"userId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["userId","name"],"additionalProperties":true},"startsAt":{"type":"string"},"endsAt":{"type":"string"},"source":{"type":"string"},"layerName":{"anyOf":[{"type":"string"},{"type":"null"}]},"overrideId":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["user","startsAt","endsAt","source","layerName","overrideId"],"additionalProperties":true},{"type":"null"}]},"next":{"anyOf":[{"type":"object","properties":{"user":{"type":"object","properties":{"userId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["userId","name"],"additionalProperties":true},"startsAt":{"type":"string"},"endsAt":{"type":"string"},"source":{"type":"string"},"layerName":{"anyOf":[{"type":"string"},{"type":"null"}]},"overrideId":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["user","startsAt","endsAt","source","layerName","overrideId"],"additionalProperties":true},{"type":"null"}]}},"required":["scheduleId","scheduleName","timeZone","at","onCall","next"],"additionalProperties":true},"annotations":{"title":"Who Is On Call","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"create_oncall_schedule","title":"Create On-Call Schedule","description":"Create an on-call schedule: layers that each rotate workspace members daily, weekly or every N hours in one time zone, optionally limited to weekly windows. Later layers sit on top of earlier ones; overrides (create_oncall_override) beat every layer.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"Schedule name, e.g. 'Checkout primary'"},"time_zone":{"type":"string","description":"IANA time zone handoffs are read in, e.g. 'Europe/Berlin'"},"layers":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":100,"description":"Display name, 1 to 100 characters.","examples":["Primary"]},"participants":{"type":"array","items":{"type":"string","minLength":1,"pattern":"^\\S[\\s\\S]*\\S$|^\\S$|^$","title":"User ID","description":"A Maple user id (`user_\u2026`) of a member of your organization."},"minItems":1,"maxItems":50,"description":"Who rotates through the layer, in order: 1 to 50 members."},"rotation":{"anyOf":[{"type":"object","properties":{"kind":{"type":"string","enum":["daily"],"description":"Hand off every day at `handoff_time`."},"handoff_time":{"type":"string","pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","description":"Local wall-clock time of the handoff in the schedule's time zone, `HH:MM`.","examples":["09:00"]}},"required":["kind","handoff_time"],"additionalProperties":false},{"type":"object","properties":{"kind":{"type":"string","enum":["weekly"],"description":"Hand off once a week on `handoff_weekday` at `handoff_time`."},"handoff_weekday":{"type":"string","enum":["mon","tue","wed","thu","fri","sat","sun"]},"handoff_time":{"type":"string","pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","description":"Local wall-clock time of the handoff in the schedule's time zone, `HH:MM`.","examples":["09:00"]}},"required":["kind","handoff_weekday","handoff_time"],"additionalProperties":false},{"type":"object","properties":{"kind":{"type":"string","enum":["custom"],"description":"Fixed-length shifts of `length_hours` elapsed hours, from the layer's `starts_at`."},"length_hours":{"type":"integer","minimum":1,"maximum":336,"description":"Shift length in hours, 1 to 336.","examples":[12]}},"required":["kind","length_hours"],"additionalProperties":false}],"title":"On-call rotation","description":"When the layer hands off from one participant to the next."},"starts_at":{"type":"string"},"restrictions":{"type":"array","items":{"type":"object","properties":{"weekdays":{"type":"array","items":{"type":"string","enum":["mon","tue","wed","thu","fri","sat","sun"]},"minItems":1,"maxItems":7,"description":"Days the window starts on."},"start_time":{"type":"string","pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","description":"Local start time, `HH:MM`.","examples":["18:00"]},"end_time":{"type":"string","pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","description":"Local end time, `HH:MM`. At or before `start_time` crosses midnight.","examples":["08:00"]}},"required":["weekdays","start_time","end_time"],"additionalProperties":false,"title":"On-call restriction","description":"A weekly local window the layer is active in."},"maxItems":7,"description":"Weekly windows the layer is limited to, at most 7. Omitted or empty means always active."}},"required":["name","participants","rotation","starts_at"],"additionalProperties":false,"title":"On-call layer","description":"One rotation. Later layers sit on top of earlier ones wherever they have someone on call."},"minItems":1,"maxItems":5,"description":"The layers, bottom first: 1 to 5."},"description":"Layers, bottom first; later layers win where they have someone on call. Each: {\"name\",\"participants\":[\"user_\u2026\"],\"rotation\":{\"kind\":\"weekly\",\"handoff_weekday\":\"mon\",\"handoff_time\":\"09:00\"} | {\"kind\":\"daily\",\"handoff_time\":\"09:00\"} | {\"kind\":\"custom\",\"length_hours\":12},\"starts_at\":\"2026-11-09T08:00:00Z\" (participant 0 holds the shift containing it),\"restrictions\":[{\"weekdays\":[\"mon\"],\"start_time\":\"18:00\",\"end_time\":\"08:00\"}] (optional)}. Participants are Maple user ids of workspace members. At most 5 layers"},"description":{"type":"string","description":"Free-form notes"}},"required":["name","time_zone","layers"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"schedule":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"timeZone":{"type":"string"},"layers":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"participants":{"type":"array","items":{"type":"object","properties":{"userId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["userId","name"],"additionalProperties":true}},"rotation":{"type":"string"},"startsAt":{"type":"string"},"restrictions":{"type":"array","items":{"type":"string"}}},"required":["name","participants","rotation","startsAt","restrictions"],"additionalProperties":true}},"onCallNow":{"anyOf":[{"type":"object","properties":{"user":{"type":"object","properties":{"userId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["userId","name"],"additionalProperties":true},"startsAt":{"type":"string"},"endsAt":{"type":"string"}},"required":["user","startsAt","endsAt"],"additionalProperties":true},{"type":"null"}]},"chainIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","timeZone","layers","onCallNow","chainIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["schedule"],"additionalProperties":true},"annotations":{"title":"Create On-Call Schedule","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, + {"name":"update_oncall_schedule","title":"Update On-Call Schedule","description":"Change an on-call schedule's name, time zone, layers or description. Pass only what changes; layers replaces every layer. Overrides are kept. Ids from list_oncall_schedules.","inputSchema":{"type":"object","properties":{"schedule_id":{"type":"string","description":"On-call schedule to change: its UUID or its REST id (onc_\u2026)"},"name":{"type":"string","description":"New name"},"time_zone":{"type":"string","description":"New IANA time zone"},"layers":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":100,"description":"Display name, 1 to 100 characters.","examples":["Primary"]},"participants":{"type":"array","items":{"type":"string","minLength":1,"pattern":"^\\S[\\s\\S]*\\S$|^\\S$|^$","title":"User ID","description":"A Maple user id (`user_\u2026`) of a member of your organization."},"minItems":1,"maxItems":50,"description":"Who rotates through the layer, in order: 1 to 50 members."},"rotation":{"anyOf":[{"type":"object","properties":{"kind":{"type":"string","enum":["daily"],"description":"Hand off every day at `handoff_time`."},"handoff_time":{"type":"string","pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","description":"Local wall-clock time of the handoff in the schedule's time zone, `HH:MM`.","examples":["09:00"]}},"required":["kind","handoff_time"],"additionalProperties":false},{"type":"object","properties":{"kind":{"type":"string","enum":["weekly"],"description":"Hand off once a week on `handoff_weekday` at `handoff_time`."},"handoff_weekday":{"type":"string","enum":["mon","tue","wed","thu","fri","sat","sun"]},"handoff_time":{"type":"string","pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","description":"Local wall-clock time of the handoff in the schedule's time zone, `HH:MM`.","examples":["09:00"]}},"required":["kind","handoff_weekday","handoff_time"],"additionalProperties":false},{"type":"object","properties":{"kind":{"type":"string","enum":["custom"],"description":"Fixed-length shifts of `length_hours` elapsed hours, from the layer's `starts_at`."},"length_hours":{"type":"integer","minimum":1,"maximum":336,"description":"Shift length in hours, 1 to 336.","examples":[12]}},"required":["kind","length_hours"],"additionalProperties":false}],"title":"On-call rotation","description":"When the layer hands off from one participant to the next."},"starts_at":{"type":"string"},"restrictions":{"type":"array","items":{"type":"object","properties":{"weekdays":{"type":"array","items":{"type":"string","enum":["mon","tue","wed","thu","fri","sat","sun"]},"minItems":1,"maxItems":7,"description":"Days the window starts on."},"start_time":{"type":"string","pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","description":"Local start time, `HH:MM`.","examples":["18:00"]},"end_time":{"type":"string","pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","description":"Local end time, `HH:MM`. At or before `start_time` crosses midnight.","examples":["08:00"]}},"required":["weekdays","start_time","end_time"],"additionalProperties":false,"title":"On-call restriction","description":"A weekly local window the layer is active in."},"maxItems":7,"description":"Weekly windows the layer is limited to, at most 7. Omitted or empty means always active."}},"required":["name","participants","rotation","starts_at"],"additionalProperties":false,"title":"On-call layer","description":"One rotation. Later layers sit on top of earlier ones wherever they have someone on call."},"minItems":1,"maxItems":5,"description":"The layers, bottom first: 1 to 5."},"description":"Replaces every layer. Layers, bottom first; later layers win where they have someone on call. Each: {\"name\",\"participants\":[\"user_\u2026\"],\"rotation\":{\"kind\":\"weekly\",\"handoff_weekday\":\"mon\",\"handoff_time\":\"09:00\"} | {\"kind\":\"daily\",\"handoff_time\":\"09:00\"} | {\"kind\":\"custom\",\"length_hours\":12},\"starts_at\":\"2026-11-09T08:00:00Z\" (participant 0 holds the shift containing it),\"restrictions\":[{\"weekdays\":[\"mon\"],\"start_time\":\"18:00\",\"end_time\":\"08:00\"}] (optional)}. Participants are Maple user ids of workspace members. At most 5 layers"},"description":{"type":"string","description":"New notes"}},"required":["schedule_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"schedule":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"timeZone":{"type":"string"},"layers":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"participants":{"type":"array","items":{"type":"object","properties":{"userId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["userId","name"],"additionalProperties":true}},"rotation":{"type":"string"},"startsAt":{"type":"string"},"restrictions":{"type":"array","items":{"type":"string"}}},"required":["name","participants","rotation","startsAt","restrictions"],"additionalProperties":true}},"onCallNow":{"anyOf":[{"type":"object","properties":{"user":{"type":"object","properties":{"userId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["userId","name"],"additionalProperties":true},"startsAt":{"type":"string"},"endsAt":{"type":"string"}},"required":["user","startsAt","endsAt"],"additionalProperties":true},{"type":"null"}]},"chainIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","timeZone","layers","onCallNow","chainIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["schedule"],"additionalProperties":true},"annotations":{"title":"Update On-Call Schedule","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"delete_oncall_schedule","title":"Delete On-Call Schedule","description":"Delete an on-call schedule and every override on it. Ids from list_oncall_schedules.","inputSchema":{"type":"object","properties":{"schedule_id":{"type":"string","description":"On-call schedule to delete: its UUID or its REST id (onc_\u2026)"}},"required":["schedule_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"additionalProperties":true},"annotations":{"title":"Delete On-Call Schedule","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}}, + {"name":"create_oncall_override","title":"Create On-Call Override","description":"Put one workspace member on call for a window, over every layer of a schedule: a swap, cover for time off, a holiday. At most 90 days, ending in the future. The newest overlapping override wins. Ids from list_oncall_schedules.","inputSchema":{"type":"object","properties":{"schedule_id":{"type":"string","description":"On-call schedule to override: its UUID or its REST id (onc_\u2026)"},"user_id":{"type":"string","description":"Maple user id (user_\u2026) of the member who covers"},"starts_at":{"type":"string","description":"When the cover starts, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601)"},"ends_at":{"type":"string","description":"When the cover ends, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601)"}},"required":["schedule_id","user_id","starts_at","ends_at"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"scheduleName":{"type":"string"},"override":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"scheduleId":{"type":"string"},"user":{"type":"object","properties":{"userId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["userId","name"],"additionalProperties":true},"startsAt":{"type":"string"},"endsAt":{"type":"string"}},"required":["id","publicId","scheduleId","user","startsAt","endsAt"],"additionalProperties":true}},"required":["scheduleName","override"],"additionalProperties":true},"annotations":{"title":"Create On-Call Override","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, + {"name":"delete_oncall_override","title":"Delete On-Call Override","description":"Delete an override so the schedule's layers decide again. Override ids come from create_oncall_override or the REST overrides list.","inputSchema":{"type":"object","properties":{"schedule_id":{"type":"string","description":"On-call schedule the override is on: its UUID or its REST id (onc_\u2026)"},"override_id":{"type":"string","description":"Override to delete: its UUID or its REST id (ovr_\u2026)"}},"required":["schedule_id","override_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"scheduleId":{"type":"string"},"scheduleName":{"type":"string"}},"required":["id","scheduleId","scheduleName"],"additionalProperties":true},"annotations":{"title":"Delete On-Call Override","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"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"}]},"spaceAggregation":{"anyOf":[{"type":"string","enum":["sum","avg","min","max","count"]},{"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__/oncall-schedule-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/oncall-schedule-tools.test.ts new file mode 100644 index 0000000000..56446c676d --- /dev/null +++ b/apps/ai/src/mcp/tools/__tests__/oncall-schedule-tools.test.ts @@ -0,0 +1,371 @@ +/** + * The on-call tools through the registry: layer input decoded to the service request, people + * rendered by display name, schedules found by name, and service failures as parameter errors. + */ +import { describe, expect, it } from "vitest" +import { Context, Effect, Layer, Schema } from "effect" +import { + AlertValidationError, + OnCallAtResponse, + OnCallOverrideDocument, + OnCallOverrideId, + OnCallOverrideNotFoundError, + OnCallScheduleDocument, + OnCallScheduleId, + OnCallScheduleNotFoundError, + OnCallSchedulesListResponse, + OnCallShiftsResponse, + OrgId, + UserId, + type OnCallOverrideCreateRequest, + type OnCallScheduleCreateRequest, + type OnCallScheduleUpdateRequest, +} from "@maple/domain/http" +import { encodePublicId } from "@maple/domain/http/v2" +import { ListOnCallSchedulesOutput, WhoIsOnCallOutput } from "@maple/domain/mcp-outputs" +import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" +import { CurrentMcpTenant } from "../../lib/query-warehouse" +import { executeRegisteredMcpToolUnscoped } from "../registry" +import type { McpToolResult } from "../types" + +const SCHEDULE_ID = "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d" +const OVERRIDE_ID = "6b5c4d3e-2f10-4b8c-9d8e-7f6a5b4c3d2e" +const MISSING_ID = "9b8c7d6e-5f4a-4b3c-9d2e-1f0a9b8c7d6e" +const NOW = "2026-11-06T10:00:00.000Z" +const WEEK = { + name: "Primary", + participants: ["user_ana", "user_ben", "user_chen"], + rotation: { kind: "weekly", handoffWeekday: "mon", handoffTime: "09:00" }, + startsAt: Date.parse("2026-11-09T08:00:00Z"), +} as const + +const scheduleDoc = (overrides: Partial = {}) => + Schema.decodeSync(OnCallScheduleDocument)({ + id: SCHEDULE_ID, + name: "Checkout primary", + description: null, + timeZone: "Europe/Berlin", + layers: [WEEK], + onCallNow: { + userId: "user_ana", + startsAt: "2026-11-09T08:00:00.000Z", + endsAt: "2026-11-16T08:00:00.000Z", + }, + chainIds: [], + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }) + +const notFound = (id: string) => + new OnCallScheduleNotFoundError({ + message: "On-call schedule not found", + scheduleId: Schema.decodeSync(OnCallScheduleId)(id), + }) + +interface Seen { + created?: OnCallScheduleCreateRequest + updated?: OnCallScheduleUpdateRequest + overrideCreated?: OnCallOverrideCreateRequest + deletedSchedule?: string + deletedOverride?: string + onCallAt?: number + /** The service refuses writes as an unknown member. */ + rejectMember?: boolean +} + +const rejectMember = () => { + const message = "Not members of this workspace: user_zed" + return Effect.fail(new AlertValidationError({ message, details: [message] })) +} + +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", + }), + Layer.mock(OnCallSchedulesService)({ + memberNames: () => + Effect.succeed( + new Map([ + ["user_ana", "Ana Alves"], + ["user_ben", "Ben Brook"], + ]), + ), + list: () => Effect.succeed(new OnCallSchedulesListResponse({ schedules: [scheduleDoc()] })), + get: (_org, id) => + id === SCHEDULE_ID ? Effect.succeed(scheduleDoc()) : Effect.fail(notFound(id)), + create: (_org, _user, _roles, request) => { + seen.created = request + if (seen.rejectMember === true) return rejectMember() + return Effect.succeed(scheduleDoc({ name: request.name, timeZone: request.timeZone })) + }, + update: (_org, _user, _roles, id, request) => { + seen.updated = request + return id === SCHEDULE_ID ? Effect.succeed(scheduleDoc()) : Effect.fail(notFound(id)) + }, + delete: (_org, _roles, id) => { + seen.deletedSchedule = id + return Effect.void + }, + onCallAt: (_org, _id, atMs) => { + seen.onCallAt = atMs + return Effect.succeed( + Schema.decodeSync(OnCallAtResponse)({ + at: new Date(atMs).toISOString(), + shift: { + userId: "user_ben", + startsAt: "2026-11-12T17:00:00.000Z", + endsAt: "2026-11-13T08:00:00.000Z", + source: { kind: "override", overrideId: OVERRIDE_ID }, + }, + }), + ) + }, + shifts: () => + Effect.succeed( + Schema.decodeSync(OnCallShiftsResponse)({ + from: "2026-11-13T08:00:00.000Z", + to: "2026-11-27T08:00:00.000Z", + layers: [], + final: [ + { + userId: "user_ana", + startsAt: "2026-11-13T08:00:00.000Z", + endsAt: "2026-11-16T08:00:00.000Z", + source: { kind: "layer", layerIndex: 0 }, + }, + ], + }), + ), + createOverride: (_org, _user, _roles, id, request) => { + seen.overrideCreated = request + if (seen.rejectMember === true) return rejectMember() + return Effect.succeed( + Schema.decodeSync(OnCallOverrideDocument)({ + id: OVERRIDE_ID, + scheduleId: id, + userId: request.userId, + startsAt: new Date(request.startsAt).toISOString(), + endsAt: new Date(request.endsAt).toISOString(), + createdBy: "user_tools", + createdAt: NOW, + }), + ) + }, + deleteOverride: (_org, _roles, _id, overrideId) => { + seen.deletedOverride = overrideId + return overrideId === OVERRIDE_ID + ? Effect.void + : Effect.fail( + new OnCallOverrideNotFoundError({ + message: "On-call override not found", + overrideId: Schema.decodeSync(OnCallOverrideId)(overrideId), + }), + ) + }, + }), + ) + +// 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 ?? "" + +describe("on-call schedule tools", () => { + it("list_oncall_schedules names people by display name and falls back to the user id", async () => { + const result = await ok("list_oncall_schedules", {}) + const output = Schema.decodeUnknownSync(ListOnCallSchedulesOutput)(result.structuredContent) + expect(output.schedules[0]?.publicId).toBe(encodePublicId("onc", SCHEDULE_ID)) + expect(output.schedules[0]?.onCallNow?.user).toEqual({ userId: "user_ana", name: "Ana Alves" }) + expect(text(result)).toContain("Ana Alves (user_ana) on call until 2026-11-16T08:00:00.000Z") + expect(text(result)).toContain("Primary: weekly, Monday 09:00") + expect(text(result)).toContain("Ana Alves, Ben Brook, user_chen") + }) + + it("who_is_on_call finds a schedule by name, reads `at` and says who covers and who is next", async () => { + const seen: Seen = {} + const result = await ok( + "who_is_on_call", + { schedule: "checkout PRIMARY", at: "2026-11-12 19:00:00" }, + seen, + ) + expect(seen.onCallAt).toBe(Date.parse("2026-11-12T19:00:00Z")) + const output = Schema.decodeUnknownSync(WhoIsOnCallOutput)(result.structuredContent) + expect(output.onCall?.user).toEqual({ userId: "user_ben", name: "Ben Brook" }) + expect(output.next?.user.userId).toBe("user_ana") + expect(text(result)).toContain( + "Ben Brook (user_ben) is on call for Checkout primary at 2026-11-12T19:00:00.000Z, through an override", + ) + expect(text(result)).toContain("Ana Alves (user_ana) from 2026-11-13T08:00:00.000Z") + }) + + it("who_is_on_call takes a REST id and refuses an unknown schedule on the parameter", async () => { + await ok("who_is_on_call", { schedule: encodePublicId("onc", SCHEDULE_ID) }) + const byId = await run("who_is_on_call", { schedule: MISSING_ID }, {}) + const byName = await run("who_is_on_call", { schedule: "Nonexistent rota" }, {}) + expect([invalidParameter(byId), invalidParameter(byName)]).toEqual(["schedule", "schedule"]) + }) + + it("create_oncall_schedule decodes JSON layers into the request and maps an unknown member to layers", async () => { + const seen: Seen = {} + await ok( + "create_oncall_schedule", + { + name: "Checkout primary", + time_zone: "Europe/Berlin", + layers: JSON.stringify([ + { + name: "Primary", + participants: ["user_ana", "user_ben"], + rotation: { kind: "weekly", handoff_weekday: "mon", handoff_time: "09:00" }, + starts_at: "2026-11-09T08:00:00.000Z", + restrictions: [{ weekdays: ["mon"], start_time: "18:00", end_time: "08:00" }], + }, + ]), + }, + seen, + ) + expect(seen.created?.layers).toEqual([ + { + name: "Primary", + participants: ["user_ana", "user_ben"], + rotation: { kind: "weekly", handoffWeekday: "mon", handoffTime: "09:00" }, + startsAt: Date.parse("2026-11-09T08:00:00Z"), + restrictions: [{ weekdays: ["mon"], startTime: "18:00", endTime: "08:00" }], + }, + ]) + + const empty = await run("create_oncall_schedule", { name: "E", time_zone: "UTC", layers: [] }, {}) + // Rejected by the parameter schema before the handler runs. + expect(empty._tag === "Failure" ? empty.failure.message : "").toContain("`layers`") + const badZone = await run( + "create_oncall_schedule", + { + name: "Z", + time_zone: "Mars/Olympus", + layers: [ + { + ...WEEK, + participants: ["user_ana"], + rotation: { kind: "daily", handoff_time: "09:00" }, + starts_at: NOW, + }, + ], + }, + {}, + ) + expect(invalidParameter(badZone)).toBe("time_zone") + const stranger = await run( + "create_oncall_schedule", + { + name: "S", + time_zone: "UTC", + layers: [ + { + name: "P", + participants: ["user_zed"], + rotation: { kind: "custom", length_hours: 12 }, + starts_at: NOW, + }, + ], + }, + { rejectMember: true }, + ) + expect(invalidParameter(stranger)).toBe("layers") + }) + + it("update_oncall_schedule sends only the given fields and needs at least one", async () => { + const seen: Seen = {} + await ok( + "update_oncall_schedule", + { schedule_id: encodePublicId("onc", SCHEDULE_ID), name: "Renamed" }, + seen, + ) + expect(seen.updated).toEqual({ name: "Renamed" }) + const nothing = await run("update_oncall_schedule", { schedule_id: SCHEDULE_ID }, {}) + expect(invalidParameter(nothing)).toBe("layers") + const missing = await run("update_oncall_schedule", { schedule_id: MISSING_ID, name: "x" }, {}) + expect(invalidParameter(missing)).toBe("schedule_id") + }) + + it("create_oncall_override converts times and names who covers; delete maps an unknown override", async () => { + const seen: Seen = {} + const created = await ok( + "create_oncall_override", + { + schedule_id: SCHEDULE_ID, + user_id: "user_ben", + starts_at: "2026-11-12T17:00:00Z", + ends_at: "2026-11-13 08:00:00", + }, + seen, + ) + expect(seen.overrideCreated).toEqual({ + userId: "user_ben", + startsAt: Date.parse("2026-11-12T17:00:00Z"), + endsAt: Date.parse("2026-11-13T08:00:00Z"), + }) + expect(text(created)).toContain( + "Ben Brook (user_ben) covers Checkout primary from 2026-11-12T17:00:00.000Z", + ) + + const stranger = await run( + "create_oncall_override", + { + schedule_id: SCHEDULE_ID, + user_id: "user_zed", + starts_at: NOW, + ends_at: "2026-11-07T10:00:00Z", + }, + { rejectMember: true }, + ) + expect(invalidParameter(stranger)).toBe("user_id") + + const deleteSeen: Seen = {} + await ok( + "delete_oncall_override", + { schedule_id: SCHEDULE_ID, override_id: encodePublicId("ovr", OVERRIDE_ID) }, + deleteSeen, + ) + expect(deleteSeen.deletedOverride).toBe(OVERRIDE_ID) + const unknown = await run( + "delete_oncall_override", + { schedule_id: SCHEDULE_ID, override_id: MISSING_ID }, + {}, + ) + expect(invalidParameter(unknown)).toBe("override_id") + }) + + it("delete_oncall_schedule names what it deleted and refuses an unknown schedule", async () => { + const seen: Seen = {} + const result = await ok("delete_oncall_schedule", { schedule_id: SCHEDULE_ID }, seen) + expect(seen.deletedSchedule).toBe(SCHEDULE_ID) + expect(text(result)).toContain("Checkout primary") + const missing = await run("delete_oncall_schedule", { schedule_id: MISSING_ID }, {}) + expect(invalidParameter(missing)).toBe("schedule_id") + }) +}) diff --git a/apps/ai/src/mcp/tools/create-oncall-override.ts b/apps/ai/src/mcp/tools/create-oncall-override.ts new file mode 100644 index 0000000000..8ac9f87c7c --- /dev/null +++ b/apps/ai/src/mcp/tools/create-oncall-override.ts @@ -0,0 +1,92 @@ +import { McpInvalidInputError, type McpToolRegistrar } from "./types" +import { Effect, Schema } from "effect" +import { parseWarehouseDateTime, WarehouseTimeInput } from "@maple/query-engine" +import { OnCallOverrideCreateRequest } from "@maple/domain/http" +import { OnCallOverrideMutationOutput } from "@maple/domain/mcp-outputs" +import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import { + createOverrideErrors, + deleteScheduleErrors, + memberNames, + personLabel, + toOverrideRow, +} from "../lib/oncall-schedules" +import * as P from "../lib/params" +import { doc } from "../lib/tool-doc" + +const decodeCreateRequest = Schema.decodeUnknownEffect(OnCallOverrideCreateRequest) +const timestamp = (meaning: string) => + Schema.String.annotate({ description: `${meaning}, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601)` }).pipe( + Schema.decodeTo(WarehouseTimeInput), + ) + +export function registerCreateOnCallOverrideTool(server: McpToolRegistrar) { + server.define({ + name: "create_oncall_override", + title: "Create On-Call Override", + description: + "Put one workspace member on call for a window, over every layer of a schedule: a swap, cover for time off, a holiday. " + + "At most 90 days, ending in the future. The newest overlapping override wins. Ids from list_oncall_schedules.", + parameters: Schema.Struct({ + schedule_id: P.resourceId("oncallSchedule", "On-call schedule to override"), + user_id: P.text("Maple user id (user_…) of the member who covers"), + starts_at: timestamp("When the cover starts"), + ends_at: timestamp("When the cover ends"), + }), + output: OnCallOverrideMutationOutput, + hints: { readOnly: false, destructive: false, idempotent: false }, + phrases: ["Creating an on-call override"], + handler: Effect.fn("McpTool.createOnCallOverride")(function* (params) { + const scheduleId = yield* P.decodeResourceId("oncallSchedule", "schedule_id")(params.schedule_id) + const request = yield* decodeCreateRequest({ + userId: params.user_id, + startsAt: parseWarehouseDateTime(params.starts_at), + endsAt: parseWarehouseDateTime(params.ends_at), + }).pipe( + Effect.mapError( + (error) => + new McpInvalidInputError({ + message: `Invalid on-call override: ${String(error)}`, + parameter: "user_id", + }), + ), + ) + const tenant = yield* CurrentMcpTenant + const schedules = yield* OnCallSchedulesService + const schedule = yield* schedules.get(tenant.orgId, scheduleId).pipe( + Effect.catchTags({ + "@maple/http/errors/OnCallScheduleNotFoundError": + deleteScheduleErrors["@maple/http/errors/OnCallScheduleNotFoundError"], + "@maple/http/errors/AlertPersistenceError": + createOverrideErrors["@maple/http/errors/AlertPersistenceError"], + }), + ) + const override = yield* schedules + .createOverride(tenant.orgId, tenant.userId, tenant.roles, scheduleId, request) + .pipe(Effect.catchTags(createOverrideErrors)) + const names = yield* memberNames(tenant.orgId) + return { scheduleName: schedule.name, override: toOverrideRow(override, names) } + }), + render: (output) => ({ + title: "On-Call Override Created", + blocks: [ + doc.text( + `${personLabel(output.override.user)} covers ${output.scheduleName} from ${output.override.startsAt} until ${output.override.endsAt}.`, + ), + doc.fields([ + ["ID", output.override.id], + ["REST id", output.override.publicId], + ["Schedule", output.override.scheduleId], + ]), + ], + next: [ + doc.next( + "who_is_on_call", + { schedule: output.override.scheduleId, at: output.override.startsAt }, + "check the cover", + ), + ], + }), + }) +} diff --git a/apps/ai/src/mcp/tools/create-oncall-schedule.ts b/apps/ai/src/mcp/tools/create-oncall-schedule.ts new file mode 100644 index 0000000000..a7437f6583 --- /dev/null +++ b/apps/ai/src/mcp/tools/create-oncall-schedule.ts @@ -0,0 +1,66 @@ +import { McpInvalidInputError, type McpToolRegistrar } from "./types" +import { Effect, Schema } from "effect" +import { OnCallScheduleCreateRequest } from "@maple/domain/http" +import { OnCallScheduleMutationOutput } from "@maple/domain/mcp-outputs" +import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import { + createScheduleErrors, + LayersInput, + layersParameterDescription, + memberNames, + scheduleBlocks, + toLayers, + toScheduleRow, +} from "../lib/oncall-schedules" +import * as P from "../lib/params" +import { doc } from "../lib/tool-doc" + +const decodeCreateRequest = Schema.decodeUnknownEffect(OnCallScheduleCreateRequest) + +export function registerCreateOnCallScheduleTool(server: McpToolRegistrar) { + server.define({ + name: "create_oncall_schedule", + title: "Create On-Call Schedule", + description: + "Create an on-call schedule: layers that each rotate workspace members daily, weekly or every N hours in one time zone, optionally limited to weekly windows. " + + "Later layers sit on top of earlier ones; overrides (create_oncall_override) beat every layer.", + parameters: Schema.Struct({ + name: P.text("Schedule name, e.g. 'Checkout primary'"), + time_zone: P.text("IANA time zone handoffs are read in, e.g. 'Europe/Berlin'"), + layers: P.json(LayersInput, layersParameterDescription), + description: P.optionalText("Free-form notes"), + }), + output: OnCallScheduleMutationOutput, + hints: { readOnly: false, destructive: false, idempotent: false }, + phrases: ["Creating an on-call schedule"], + handler: Effect.fn("McpTool.createOnCallSchedule")(function* (params) { + const request = yield* decodeCreateRequest({ + name: params.name, + timeZone: params.time_zone, + layers: toLayers(params.layers), + ...(params.description === undefined ? undefined : { description: params.description }), + }).pipe( + Effect.mapError( + (error) => + new McpInvalidInputError({ + message: `Invalid on-call schedule: ${String(error)}`, + parameter: "time_zone", + }), + ), + ) + const tenant = yield* CurrentMcpTenant + const schedules = yield* OnCallSchedulesService + const schedule = yield* schedules + .create(tenant.orgId, tenant.userId, tenant.roles, request) + .pipe(Effect.catchTags(createScheduleErrors)) + const names = yield* memberNames(tenant.orgId) + return { schedule: toScheduleRow(schedule, names) } + }), + render: (output) => ({ + title: "On-Call Schedule Created", + blocks: scheduleBlocks(output.schedule), + next: [doc.next("who_is_on_call", { schedule: output.schedule.id }, "who is on call now")], + }), + }) +} diff --git a/apps/ai/src/mcp/tools/delete-oncall-override.ts b/apps/ai/src/mcp/tools/delete-oncall-override.ts new file mode 100644 index 0000000000..d004d8ac4f --- /dev/null +++ b/apps/ai/src/mcp/tools/delete-oncall-override.ts @@ -0,0 +1,52 @@ +import type { McpToolRegistrar } from "./types" +import { Effect, Schema } from "effect" +import { DeleteOnCallOverrideOutput } from "@maple/domain/mcp-outputs" +import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import { deleteOverrideErrors } from "../lib/oncall-schedules" +import * as P from "../lib/params" +import { doc } from "../lib/tool-doc" + +export function registerDeleteOnCallOverrideTool(server: McpToolRegistrar) { + server.define({ + name: "delete_oncall_override", + title: "Delete On-Call Override", + description: + "Delete an override so the schedule's layers decide again. Override ids come from create_oncall_override or the REST overrides list.", + parameters: Schema.Struct({ + schedule_id: P.resourceId("oncallSchedule", "On-call schedule the override is on"), + override_id: P.resourceId("oncallOverride", "Override to delete"), + }), + output: DeleteOnCallOverrideOutput, + hints: { readOnly: false, destructive: true, idempotent: false }, + phrases: ["Deleting an on-call override"], + handler: Effect.fn("McpTool.deleteOnCallOverride")(function* (params) { + const scheduleId = yield* P.decodeResourceId("oncallSchedule", "schedule_id")(params.schedule_id) + const overrideId = yield* P.decodeResourceId("oncallOverride", "override_id")(params.override_id) + const tenant = yield* CurrentMcpTenant + const schedules = yield* OnCallSchedulesService + const schedule = yield* schedules.get(tenant.orgId, scheduleId).pipe( + Effect.catchTags({ + "@maple/http/errors/OnCallScheduleNotFoundError": + deleteOverrideErrors["@maple/http/errors/OnCallScheduleNotFoundError"], + "@maple/http/errors/AlertPersistenceError": + deleteOverrideErrors["@maple/http/errors/AlertPersistenceError"], + }), + ) + yield* schedules + .deleteOverride(tenant.orgId, tenant.roles, scheduleId, overrideId) + .pipe(Effect.catchTags(deleteOverrideErrors)) + return { id: overrideId, scheduleId, scheduleName: schedule.name } + }), + render: (output) => ({ + title: "On-Call Override Deleted", + blocks: [ + doc.fields([ + ["ID", output.id], + ["Schedule", `${output.scheduleName} (${output.scheduleId})`], + ]), + ], + next: [doc.next("who_is_on_call", { schedule: output.scheduleId }, "who is on call now")], + }), + }) +} diff --git a/apps/ai/src/mcp/tools/delete-oncall-schedule.ts b/apps/ai/src/mcp/tools/delete-oncall-schedule.ts new file mode 100644 index 0000000000..6e532d44a0 --- /dev/null +++ b/apps/ai/src/mcp/tools/delete-oncall-schedule.ts @@ -0,0 +1,50 @@ +import type { McpToolRegistrar } from "./types" +import { Effect, Schema } from "effect" +import { DeleteOnCallScheduleOutput } from "@maple/domain/mcp-outputs" +import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import { deleteScheduleErrors } from "../lib/oncall-schedules" +import * as P from "../lib/params" +import { doc } from "../lib/tool-doc" + +export function registerDeleteOnCallScheduleTool(server: McpToolRegistrar) { + server.define({ + name: "delete_oncall_schedule", + title: "Delete On-Call Schedule", + description: "Delete an on-call schedule and every override on it. Ids from list_oncall_schedules.", + parameters: Schema.Struct({ + schedule_id: P.resourceId("oncallSchedule", "On-call schedule to delete"), + }), + output: DeleteOnCallScheduleOutput, + hints: { readOnly: false, destructive: true, idempotent: false }, + phrases: ["Deleting an on-call schedule"], + handler: Effect.fn("McpTool.deleteOnCallSchedule")(function* (params) { + const scheduleId = yield* P.decodeResourceId("oncallSchedule", "schedule_id")(params.schedule_id) + const tenant = yield* CurrentMcpTenant + const schedules = yield* OnCallSchedulesService + // Read first so the reply names what was deleted. + const schedule = yield* schedules.get(tenant.orgId, scheduleId).pipe( + Effect.catchTags({ + "@maple/http/errors/OnCallScheduleNotFoundError": + deleteScheduleErrors["@maple/http/errors/OnCallScheduleNotFoundError"], + "@maple/http/errors/AlertPersistenceError": + deleteScheduleErrors["@maple/http/errors/AlertPersistenceError"], + }), + ) + yield* schedules + .delete(tenant.orgId, tenant.roles, scheduleId) + .pipe(Effect.catchTags(deleteScheduleErrors)) + return { id: schedule.id, name: schedule.name } + }), + render: (output) => ({ + title: "On-Call Schedule Deleted", + blocks: [ + doc.fields([ + ["ID", output.id], + ["Name", output.name], + ]), + ], + next: [doc.next("list_oncall_schedules", {}, "the remaining schedules")], + }), + }) +} diff --git a/apps/ai/src/mcp/tools/list-oncall-schedules.ts b/apps/ai/src/mcp/tools/list-oncall-schedules.ts new file mode 100644 index 0000000000..f5e8706129 --- /dev/null +++ b/apps/ai/src/mcp/tools/list-oncall-schedules.ts @@ -0,0 +1,64 @@ +import type { McpToolRegistrar } from "./types" +import { Array as Arr, Effect, Schema } from "effect" +import { ListOnCallSchedulesOutput } from "@maple/domain/mcp-outputs" +import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import { toMcpHttpError } from "../lib/map-http-error" +import { formatLayers, formatOnCallNow, memberNames, toScheduleRow } from "../lib/oncall-schedules" +import { doc } from "../lib/tool-doc" + +export function registerListOnCallSchedulesTool(server: McpToolRegistrar) { + server.define({ + name: "list_oncall_schedules", + title: "List On-Call Schedules", + description: + "List on-call schedules: layered rotations in a time zone that say who is on call when, with overrides on top. " + + "Shows each schedule's layers, participants and who is on call right now. Ask about one instant with who_is_on_call.", + parameters: Schema.Struct({}), + output: ListOnCallSchedulesOutput, + hints: { readOnly: true }, + phrases: ["Listing on-call schedules"], + handler: Effect.fn("McpTool.listOnCallSchedules")(function* () { + const tenant = yield* CurrentMcpTenant + const schedules = yield* OnCallSchedulesService + const result = yield* schedules + .list(tenant.orgId) + .pipe(Effect.mapError(toMcpHttpError("list_oncall_schedules"))) + const names = + result.schedules.length === 0 ? new Map() : yield* memberNames(tenant.orgId) + yield* Effect.annotateCurrentSpan({ + orgId: tenant.orgId, + "result.rowCount": result.schedules.length, + }) + return { + schedules: Arr.map(result.schedules, (schedule) => toScheduleRow(schedule, names)), + total: result.schedules.length, + } + }), + render: (output) => ({ + title: "On-Call Schedules", + ...(output.schedules.length === 0 + ? { + empty: { + message: + "No on-call schedules yet. Create one with create_oncall_schedule from your workspace members.", + }, + } + : undefined), + blocks: Arr.flatMap(output.schedules, (schedule) => [ + doc.heading(`${schedule.name} (${schedule.id})`), + doc.text(`${schedule.timeZone}: ${formatOnCallNow(schedule)}\n${formatLayers(schedule)}`), + ]), + next: Arr.take( + Arr.map(output.schedules, (schedule) => + doc.next( + "who_is_on_call", + { schedule: schedule.id }, + `who is on call for ${schedule.name}`, + ), + ), + 3, + ), + }), + }) +} diff --git a/apps/ai/src/mcp/tools/mutating.ts b/apps/ai/src/mcp/tools/mutating.ts index 644930287d..f083ffcc81 100644 --- a/apps/ai/src/mcp/tools/mutating.ts +++ b/apps/ai/src/mcp/tools/mutating.ts @@ -22,6 +22,11 @@ export const MUTATING_TOOL_NAMES: ReadonlySet = new Set([ "create_escalation_chain", "update_escalation_chain", "delete_escalation_chain", + "create_oncall_schedule", + "update_oncall_schedule", + "delete_oncall_schedule", + "create_oncall_override", + "delete_oncall_override", // 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 399af6514a..57ea5833fa 100644 --- a/apps/ai/src/mcp/tools/registry.ts +++ b/apps/ai/src/mcp/tools/registry.ts @@ -15,6 +15,13 @@ import { registerListEscalationChainsTool } from "./list-escalation-chains" import { registerCreateEscalationChainTool } from "./create-escalation-chain" import { registerUpdateEscalationChainTool } from "./update-escalation-chain" import { registerDeleteEscalationChainTool } from "./delete-escalation-chain" +import { registerListOnCallSchedulesTool } from "./list-oncall-schedules" +import { registerWhoIsOnCallTool } from "./who-is-on-call" +import { registerCreateOnCallScheduleTool } from "./create-oncall-schedule" +import { registerUpdateOnCallScheduleTool } from "./update-oncall-schedule" +import { registerDeleteOnCallScheduleTool } from "./delete-oncall-schedule" +import { registerCreateOnCallOverrideTool } from "./create-oncall-override" +import { registerDeleteOnCallOverrideTool } from "./delete-oncall-override" import { registerCreateDashboardTool } from "./create-dashboard" import { registerDescribeDashboardSchemaTool } from "./describe-dashboard-schema" import { registerDiagnoseServiceTool } from "./diagnose-service" @@ -440,6 +447,13 @@ const collectMapleToolDefinitions = (): ReadonlyArray => { registerCreateEscalationChainTool(registrar) registerUpdateEscalationChainTool(registrar) registerDeleteEscalationChainTool(registrar) + registerListOnCallSchedulesTool(registrar) + registerWhoIsOnCallTool(registrar) + registerCreateOnCallScheduleTool(registrar) + registerUpdateOnCallScheduleTool(registrar) + registerDeleteOnCallScheduleTool(registrar) + registerCreateOnCallOverrideTool(registrar) + registerDeleteOnCallOverrideTool(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 cced04f27f..761fbbc8e4 100644 --- a/apps/ai/src/mcp/tools/runtime-requirements.ts +++ b/apps/ai/src/mcp/tools/runtime-requirements.ts @@ -5,6 +5,7 @@ import type { AlertReadModelsService } from "@maple/backend/services/alerts/Aler import type { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import type { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" import type { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" +import type { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" 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" @@ -51,6 +52,7 @@ export type McpToolRuntimeRequirements = | AlertIncidentAckService | AlertRulesService | EscalationChainsService + | OnCallSchedulesService | DashboardPersistenceService | ErrorActorsService | ErrorIssueReadModelsService diff --git a/apps/ai/src/mcp/tools/update-oncall-schedule.ts b/apps/ai/src/mcp/tools/update-oncall-schedule.ts new file mode 100644 index 0000000000..47df87e528 --- /dev/null +++ b/apps/ai/src/mcp/tools/update-oncall-schedule.ts @@ -0,0 +1,79 @@ +import { McpInvalidInputError, type McpToolRegistrar } from "./types" +import { Effect, Schema } from "effect" +import { OnCallScheduleUpdateRequest } from "@maple/domain/http" +import { OnCallScheduleMutationOutput } from "@maple/domain/mcp-outputs" +import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import { + LayersInput, + layersParameterDescription, + memberNames, + scheduleBlocks, + toLayers, + toScheduleRow, + updateScheduleErrors, +} from "../lib/oncall-schedules" +import * as P from "../lib/params" +import { doc } from "../lib/tool-doc" + +const decodeUpdateRequest = Schema.decodeUnknownEffect(OnCallScheduleUpdateRequest) + +export function registerUpdateOnCallScheduleTool(server: McpToolRegistrar) { + server.define({ + name: "update_oncall_schedule", + title: "Update On-Call Schedule", + description: + "Change an on-call schedule's name, time zone, layers or description. Pass only what changes; layers replaces every layer. " + + "Overrides are kept. Ids from list_oncall_schedules.", + parameters: Schema.Struct({ + schedule_id: P.resourceId("oncallSchedule", "On-call schedule to change"), + name: P.optionalText("New name"), + time_zone: P.optionalText("New IANA time zone"), + layers: P.optionalJson(LayersInput, `Replaces every layer. ${layersParameterDescription}`), + description: P.optionalText("New notes"), + }), + output: OnCallScheduleMutationOutput, + hints: { readOnly: false, destructive: false, idempotent: true }, + phrases: ["Updating an on-call schedule"], + handler: Effect.fn("McpTool.updateOnCallSchedule")(function* (params) { + const scheduleId = yield* P.decodeResourceId("oncallSchedule", "schedule_id")(params.schedule_id) + if ( + params.name === undefined && + params.time_zone === undefined && + params.layers === undefined && + params.description === undefined + ) { + return yield* new McpInvalidInputError({ + message: "Nothing to change: pass name, time_zone, layers or description.", + parameter: "layers", + }) + } + const request = yield* decodeUpdateRequest({ + ...(params.name === undefined ? undefined : { name: params.name }), + ...(params.time_zone === undefined ? undefined : { timeZone: params.time_zone }), + ...(params.layers === undefined ? undefined : { layers: toLayers(params.layers) }), + ...(params.description === undefined ? undefined : { description: params.description }), + }).pipe( + Effect.mapError( + (error) => + new McpInvalidInputError({ + message: `Invalid on-call schedule: ${String(error)}`, + parameter: "time_zone", + }), + ), + ) + const tenant = yield* CurrentMcpTenant + const schedules = yield* OnCallSchedulesService + const schedule = yield* schedules + .update(tenant.orgId, tenant.userId, tenant.roles, scheduleId, request) + .pipe(Effect.catchTags(updateScheduleErrors)) + const names = yield* memberNames(tenant.orgId) + return { schedule: toScheduleRow(schedule, names) } + }), + render: (output) => ({ + title: "On-Call Schedule Updated", + blocks: scheduleBlocks(output.schedule), + next: [doc.next("who_is_on_call", { schedule: output.schedule.id }, "who is on call now")], + }), + }) +} diff --git a/apps/ai/src/mcp/tools/who-is-on-call.ts b/apps/ai/src/mcp/tools/who-is-on-call.ts new file mode 100644 index 0000000000..09202319a8 --- /dev/null +++ b/apps/ai/src/mcp/tools/who-is-on-call.ts @@ -0,0 +1,91 @@ +import type { McpToolRegistrar } from "./types" +import { Array as Arr, Clock, Effect, Option, Schema } from "effect" +import { parseWarehouseDateTime } from "@maple/query-engine" +import { WhoIsOnCallOutput } from "@maple/domain/mcp-outputs" +import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import { toMcpHttpError } from "../lib/map-http-error" +import { isoMs, memberNames, personLabel, resolveSchedule, toShiftRow } from "../lib/oncall-schedules" +import * as P from "../lib/params" +import { doc } from "../lib/tool-doc" + +/** How far ahead the next shift is looked for. */ +const NEXT_SHIFT_HORIZON_MS = 14 * 86_400_000 + +export function registerWhoIsOnCallTool(server: McpToolRegistrar) { + server.define({ + name: "who_is_on_call", + title: "Who Is On Call", + description: + "Who is on call for a schedule at an instant (default now), what put them there (a layer or an override), when their shift ends and who is next. " + + "Schedules from list_oncall_schedules, by id or name.", + parameters: Schema.Struct({ + schedule: P.text("On-call schedule: its UUID, its REST id (onc_…) or its name"), + at: P.optionalTimestamp("The instant to ask about (default now)"), + }), + output: WhoIsOnCallOutput, + hints: { readOnly: true }, + phrases: ["Checking who is on call"], + handler: Effect.fn("McpTool.whoIsOnCall")(function* (params) { + const tenant = yield* CurrentMcpTenant + const schedules = yield* OnCallSchedulesService + const schedule = yield* resolveSchedule(tenant.orgId, params.schedule, "schedule") + const atMs = + params.at === undefined ? yield* Clock.currentTimeMillis : parseWarehouseDateTime(params.at) + const toError = Effect.mapError(toMcpHttpError("who_is_on_call")) + const current = yield* schedules.onCallAt(tenant.orgId, schedule.id, atMs).pipe(toError) + const nextFrom = current.shift === null ? atMs : isoMs(current.shift.endsAt) + const upcoming = yield* schedules + .shifts(tenant.orgId, schedule.id, nextFrom, nextFrom + NEXT_SHIFT_HORIZON_MS) + .pipe( + Effect.catchTag("@maple/http/errors/AlertValidationError", () => Effect.succeed(null)), + toError, + ) + const next = + upcoming === null + ? Option.none() + : Arr.findFirst(upcoming.final, (s) => isoMs(s.startsAt) >= nextFrom) + const names = yield* memberNames(tenant.orgId) + yield* Effect.annotateCurrentSpan({ + orgId: tenant.orgId, + "maple.oncall.schedule_id": schedule.id, + }) + return { + scheduleId: schedule.id, + scheduleName: schedule.name, + timeZone: schedule.timeZone, + at: current.at, + onCall: current.shift === null ? null : toShiftRow(current.shift, schedule.layers, names), + next: Option.match(next, { + onNone: () => null, + onSome: (shift) => toShiftRow(shift, schedule.layers, names), + }), + } + }), + render: (output) => { + const why = (shift: NonNullable) => + shift.source === "override" ? "an override" : `layer "${shift.layerName ?? "?"}"` + return { + title: `On Call: ${output.scheduleName}`, + blocks: [ + doc.text( + output.onCall === null + ? `Nobody is on call for ${output.scheduleName} at ${output.at}.` + : `${personLabel(output.onCall.user)} is on call for ${output.scheduleName} at ${output.at}, through ${why(output.onCall)}, from ${output.onCall.startsAt} until ${output.onCall.endsAt}.`, + ), + doc.fields([ + ["Schedule", `${output.scheduleName} (${output.scheduleId})`], + ["Time zone", output.timeZone], + [ + "Next", + output.next === null + ? "nobody within two weeks" + : `${personLabel(output.next.user)} from ${output.next.startsAt} until ${output.next.endsAt}`, + ], + ]), + ], + next: [doc.next("list_oncall_schedules", {}, "every schedule and its layers")], + } + }, + }) +} diff --git a/apps/ai/test/mcp/unimplemented-tool-services.ts b/apps/ai/test/mcp/unimplemented-tool-services.ts index ef0bd3d9a7..ade3e8d50a 100644 --- a/apps/ai/test/mcp/unimplemented-tool-services.ts +++ b/apps/ai/test/mcp/unimplemented-tool-services.ts @@ -8,6 +8,7 @@ import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertRead import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" +import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" import { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService" import { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService" import { ErrorIssueReadModelsService } from "@maple/backend/services/errors/ErrorIssueReadModelsService" @@ -47,6 +48,7 @@ export const unimplementedMcpToolServices = Layer.mergeAll( Layer.mock(AlertIncidentAckService)({}), Layer.mock(AlertRulesService)({}), Layer.mock(EscalationChainsService)({}), + Layer.mock(OnCallSchedulesService)({}), Layer.mock(DashboardPersistenceService)({}), Layer.mock(ErrorActorsService)({}), Layer.mock(ErrorIssueReadModelsService)({}), diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index e5201d1af8..6e3e2ddc2a 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -1465,4 +1465,192 @@ describe("v2 alerts over HTTP", () => { await harness.dispose() }) + + // Weekly Monday 09:00 Berlin from Monday 2029-12-31: Ana, then Ben, then Chen. + const weeklySchedule = { + name: "Checkout primary", + time_zone: "Europe/Berlin", + layers: [ + { + name: "Primary", + participants: ["user_ana", "user_ben", "user_chen"], + rotation: { kind: "weekly", handoff_weekday: "mon", handoff_time: "09:00" }, + starts_at: "2029-12-31T08:00:00.000Z", + }, + ], + } + + it("runs on-call schedule CRUD, answers who is on call and renders shifts around an override", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(["alerts:write"]) + + const created = await harness.request( + "POST", + "/v2/alerts/oncall_schedules", + key.secret, + weeklySchedule, + ) + expect(created.status, JSON.stringify(created.body)).toBe(200) + expect(created.body).toMatchObject({ + object: "oncall_schedule", + ...weeklySchedule, + description: null, + chain_ids: [], + }) + expect(created.body.id).toMatch(/^onc_/) + const id: string = created.body.id + const base = `/v2/alerts/oncall_schedules/${id}` + + const listed = await harness.request("GET", "/v2/alerts/oncall_schedules", key.secret) + expect(listed.status).toBe(200) + expect(listed.body.data.map((schedule: { id: string }) => schedule.id)).toEqual([id]) + + const override = await harness.request("POST", `${base}/overrides`, key.secret, { + user_id: "user_ben", + starts_at: "2030-01-03T17:00:00.000Z", + ends_at: "2030-01-04T08:00:00.000Z", + }) + expect(override.status, JSON.stringify(override.body)).toBe(200) + expect(override.body).toMatchObject({ + object: "oncall_override", + schedule_id: id, + user_id: "user_ben", + }) + expect(override.body.id).toMatch(/^ovr_/) + + const covered = await harness.request("GET", `${base}/oncall?at=2030-01-03T19:00:00.000Z`, key.secret) + expect(covered.status, JSON.stringify(covered.body)).toBe(200) + expect(covered.body).toEqual({ + object: "oncall", + schedule_id: id, + at: "2030-01-03T19:00:00.000Z", + shift: { + user_id: "user_ben", + starts_at: "2030-01-03T17:00:00.000Z", + ends_at: "2030-01-04T08:00:00.000Z", + source: { kind: "override", override_id: override.body.id }, + }, + }) + + const shifts = await harness.request( + "GET", + `${base}/shifts?from=2029-12-31T08:00:00.000Z&to=2030-01-14T08:00:00.000Z`, + key.secret, + ) + expect(shifts.status, JSON.stringify(shifts.body)).toBe(200) + expect( + shifts.body.final.map((s: { user_id: string; starts_at: string }) => [s.user_id, s.starts_at]), + ).toEqual([ + ["user_ana", "2029-12-31T08:00:00.000Z"], + ["user_ben", "2030-01-03T17:00:00.000Z"], + ["user_ana", "2030-01-04T08:00:00.000Z"], + ["user_ben", "2030-01-07T08:00:00.000Z"], + ]) + + const overrides = await harness.request("GET", `${base}/overrides`, key.secret) + expect(overrides.body.data.map((o: { id: string }) => o.id)).toEqual([override.body.id]) + const removed = await harness.request("DELETE", `${base}/overrides/${override.body.id}`, key.secret) + expect(removed.body).toEqual({ id: override.body.id, object: "oncall_override", deleted: true }) + const back = await harness.request("GET", `${base}/oncall?at=2030-01-03T19:00:00.000Z`, key.secret) + expect(back.body.shift.user_id).toBe("user_ana") + const removedAgain = await harness.request( + "DELETE", + `${base}/overrides/${override.body.id}`, + key.secret, + ) + expect(removedAgain.status).toBe(404) + expect(removedAgain.body.error.code).toBe("oncall_override_not_found") + + const renamed = await harness.request("PATCH", base, key.secret, { + name: "Checkout paging", + time_zone: "America/New_York", + }) + expect(renamed.status, JSON.stringify(renamed.body)).toBe(200) + expect(renamed.body).toMatchObject({ name: "Checkout paging", time_zone: "America/New_York" }) + + const deleted = await harness.request("DELETE", base, key.secret) + expect(deleted.body).toEqual({ id, object: "oncall_schedule", deleted: true }) + const gone = await harness.request("GET", base, key.secret) + expect(gone.status).toBe(404) + expect(gone.body.error.code).toBe("oncall_schedule_not_found") + + await harness.dispose() + }) + + it("rejects bad on-call times and layers with 400s, unknown schedules with 404 and read-only writes with 403", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(["alerts:write"]) + const created = await harness.request( + "POST", + "/v2/alerts/oncall_schedules", + key.secret, + weeklySchedule, + ) + const base = `/v2/alerts/oncall_schedules/${created.body.id}` + const [layer] = weeklySchedule.layers + + const badRequests = await Promise.all([ + harness.request("GET", `${base}/oncall?at=yesterday`, key.secret), + harness.request("GET", `${base}/shifts?from=2030-01-01`, key.secret), + harness.request( + "GET", + `${base}/shifts?from=2030-01-14T00:00:00.000Z&to=2030-01-01T00:00:00.000Z`, + key.secret, + ), + harness.request("POST", "/v2/alerts/oncall_schedules", key.secret, { + ...weeklySchedule, + time_zone: "Mars/Olympus", + }), + harness.request("POST", "/v2/alerts/oncall_schedules", key.secret, { + ...weeklySchedule, + layers: [{ ...layer, participants: [] }], + }), + harness.request("POST", `${base}/overrides`, key.secret, { + user_id: "user_ben", + starts_at: "2030-01-04T08:00:00.000Z", + ends_at: "2030-01-03T17:00:00.000Z", + }), + harness.request("POST", `${base}/overrides`, key.secret, { + user_id: "user_ben", + starts_at: "2020-01-03T17:00:00.000Z", + ends_at: "2020-01-04T08:00:00.000Z", + }), + ]) + for (const response of badRequests) { + expect(response.status, JSON.stringify(response.body)).toBe(400) + expect(response.body.error.type).toBe("invalid_request_error") + } + + const unknown = `/v2/alerts/oncall_schedules/${encodePublicId("onc", "0b8f7c1e-2f61-4f1e-9d2a-3c4b5d6e7f80")}` + const missing = await Promise.all([ + harness.request("GET", `${unknown}/oncall`, key.secret), + harness.request("PATCH", unknown, key.secret, { name: "Nope" }), + harness.request( + "DELETE", + `${base}/overrides/${encodePublicId("ovr", "0b8f7c1e-2f61-4f1e-9d2a-3c4b5d6e7f80")}`, + key.secret, + ), + ]) + expect(Arr.map(missing, (response) => response.status)).toEqual([404, 404, 404]) + + const readOnly = await harness.bootstrapKey(["alerts:read"]) + const read = await harness.request( + "GET", + `${base}/oncall?at=2030-01-03T19:00:00.000Z`, + readOnly.secret, + ) + expect(read.body.shift.user_id).toBe("user_ana") + const denied = await Promise.all([ + harness.request("POST", "/v2/alerts/oncall_schedules", readOnly.secret, weeklySchedule), + harness.request("POST", `${base}/overrides`, readOnly.secret, { + user_id: "user_ben", + starts_at: "2030-01-03T17:00:00.000Z", + ends_at: "2030-01-04T08:00:00.000Z", + }), + harness.request("DELETE", base, readOnly.secret), + ]) + expect(Arr.map(denied, (response) => response.status)).toEqual([403, 403, 403]) + + await harness.dispose() + }) }) diff --git a/apps/api/src/routes/v2/oncall-schedules.http.ts b/apps/api/src/routes/v2/oncall-schedules.http.ts new file mode 100644 index 0000000000..313268d1e7 --- /dev/null +++ b/apps/api/src/routes/v2/oncall-schedules.http.ts @@ -0,0 +1,260 @@ +import { HttpApiBuilder } from "effect/http-api" +import { + CurrentTenant, + OnCallOverrideCreateRequest, + OnCallScheduleCreateRequest, + OnCallScheduleUpdateRequest, + type OnCallOverrideDocument, + type OnCallScheduleDocument, + type OnCallScheduleId, + type OnCallShiftDocument, +} from "@maple/domain/http" +import type { + V2OnCallOverride, + V2OnCallOverrideDeleteResponse, + V2OnCallSchedule, + V2OnCallScheduleDeleteResponse, + V2OnCallScheduleUpdateParams, + V2OnCallShift, +} from "@maple/domain/http/v2" +import { + fromV2OnCallLayer, + MapleApiV2, + paginateArray, + timestamp, + toV2OnCallLayer, + v2TimestampMs, +} from "@maple/domain/http/v2" +import { Array as Arr, Clock, Effect } from "effect" +import { auditDiff } from "@/routes/v2/audit-changes" +import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService" +import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" + +const DEFAULT_SHIFTS_RANGE_MS = 14 * 86_400_000 + +const scheduleAuditDiff = auditDiff({ + fields: ["name", "description", "time_zone", "layers"], +}) + +export const toV2OnCallSchedule = (doc: OnCallScheduleDocument): V2OnCallSchedule => ({ + id: doc.id, + object: "oncall_schedule", + name: doc.name, + description: doc.description, + time_zone: doc.timeZone, + layers: Arr.map(doc.layers, toV2OnCallLayer), + on_call_now: + doc.onCallNow === null + ? null + : { + user_id: doc.onCallNow.userId, + starts_at: timestamp(doc.onCallNow.startsAt), + ends_at: timestamp(doc.onCallNow.endsAt), + }, + chain_ids: doc.chainIds, + created_at: timestamp(doc.createdAt), + updated_at: timestamp(doc.updatedAt), +}) + +const toV2Override = (doc: OnCallOverrideDocument): V2OnCallOverride => ({ + id: doc.id, + object: "oncall_override", + schedule_id: doc.scheduleId, + user_id: doc.userId, + starts_at: timestamp(doc.startsAt), + ends_at: timestamp(doc.endsAt), + created_by: doc.createdBy, + created_at: timestamp(doc.createdAt), +}) + +const toV2Shift = (shift: OnCallShiftDocument): V2OnCallShift => ({ + user_id: shift.userId, + starts_at: timestamp(shift.startsAt), + ends_at: timestamp(shift.endsAt), + source: + shift.source.kind === "override" + ? { kind: "override", override_id: shift.source.overrideId } + : { kind: "layer", layer_index: shift.source.layerIndex }, +}) + +const scheduleObject = (id: OnCallScheduleId) => ({ schedule_id: id }) + +export const HttpV2OnCallSchedulesLive = HttpApiBuilder.group(MapleApiV2, "oncallSchedules", (handlers) => + Effect.gen(function* () { + const schedules = yield* OnCallSchedulesService + + return handlers + .handle("list", ({ query }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + // The service returns the org's whole (capped) set, so pages are cut from it. + const response = yield* schedules.list(tenant.orgId) + const page = yield* paginateArray(Arr.map(response.schedules, toV2OnCallSchedule), query) + return { object: "list" as const, ...page } + }), + ) + .handle("retrieve", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const schedule = yield* schedules.get(tenant.orgId, params.id) + return toV2OnCallSchedule(schedule) + }), + ) + .handle("create", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const created = yield* schedules.create( + tenant.orgId, + tenant.userId, + tenant.roles, + new OnCallScheduleCreateRequest({ + name: payload.name, + ...(payload.description === undefined + ? undefined + : { description: payload.description }), + timeZone: payload.time_zone, + layers: Arr.map(payload.layers, fromV2OnCallLayer), + }), + ) + yield* recordHttpAudit("oncall_schedule.created", { + resourceId: created.id, + metadata: { name: created.name, layers: created.layers.length }, + }) + return toV2OnCallSchedule(created) + }), + ) + .handle("update", ({ params, payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const current = yield* schedules.get(tenant.orgId, params.id) + const updated = yield* schedules.update( + tenant.orgId, + tenant.userId, + tenant.roles, + params.id, + new OnCallScheduleUpdateRequest({ + ...(payload.name === undefined ? undefined : { name: payload.name }), + ...(payload.description === undefined + ? undefined + : { description: payload.description }), + ...(payload.time_zone === undefined + ? undefined + : { timeZone: payload.time_zone }), + ...(payload.layers === undefined + ? undefined + : { layers: Arr.map(payload.layers, fromV2OnCallLayer) }), + }), + ) + yield* recordHttpAudit("oncall_schedule.updated", { + resourceId: updated.id, + changes: scheduleAuditDiff( + payload, + toV2OnCallSchedule(current), + toV2OnCallSchedule(updated), + ), + metadata: { name: updated.name }, + }) + return toV2OnCallSchedule(updated) + }), + ) + .handle("delete", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* schedules.delete(tenant.orgId, tenant.roles, params.id) + yield* recordHttpAudit("oncall_schedule.deleted", { resourceId: params.id }) + const deleted: V2OnCallScheduleDeleteResponse = { + id: params.id, + object: "oncall_schedule", + deleted: true, + } + return deleted + }), + ) + .handle("oncall", ({ params, query }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const atMs = + query.at === undefined ? yield* Clock.currentTimeMillis : v2TimestampMs(query.at) + const result = yield* schedules.onCallAt(tenant.orgId, params.id, atMs) + return { + object: "oncall" as const, + ...scheduleObject(params.id), + at: timestamp(result.at), + shift: result.shift === null ? null : toV2Shift(result.shift), + } + }), + ) + .handle("shifts", ({ params, query }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const fromMs = + query.from === undefined ? yield* Clock.currentTimeMillis : v2TimestampMs(query.from) + const toMsValue = + query.to === undefined ? fromMs + DEFAULT_SHIFTS_RANGE_MS : v2TimestampMs(query.to) + const render = yield* schedules.shifts(tenant.orgId, params.id, fromMs, toMsValue) + return { + object: "oncall_shifts" as const, + ...scheduleObject(params.id), + from: timestamp(render.from), + to: timestamp(render.to), + layers: Arr.map(render.layers, (layer) => Arr.map(layer, toV2Shift)), + final: Arr.map(render.final, toV2Shift), + } + }), + ) + .handle("listOverrides", ({ params, query }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const response = yield* schedules.listOverrides( + tenant.orgId, + params.id, + query.from === undefined ? undefined : v2TimestampMs(query.from), + ) + const page = yield* paginateArray(Arr.map(response.overrides, toV2Override), query) + return { object: "list" as const, ...page } + }), + ) + .handle("createOverride", ({ params, payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const created = yield* schedules.createOverride( + tenant.orgId, + tenant.userId, + tenant.roles, + params.id, + new OnCallOverrideCreateRequest({ + userId: payload.user_id, + startsAt: v2TimestampMs(payload.starts_at), + endsAt: v2TimestampMs(payload.ends_at), + }), + ) + yield* recordHttpAudit("oncall_override.created", { + resourceId: created.id, + metadata: { + scheduleId: params.id, + userId: created.userId, + startsAt: created.startsAt, + endsAt: created.endsAt, + }, + }) + return toV2Override(created) + }), + ) + .handle("deleteOverride", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* schedules.deleteOverride(tenant.orgId, tenant.roles, params.id, params.override_id) + yield* recordHttpAudit("oncall_override.deleted", { + resourceId: params.override_id, + metadata: { scheduleId: params.id }, + }) + const deleted: V2OnCallOverrideDeleteResponse = { + id: params.override_id, + object: "oncall_override", + deleted: true, + } + return deleted + }), + ) + }), +) diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index 3d7ff120b0..484fea375d 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -8,6 +8,7 @@ import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertRead import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" +import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" import { AnomalyDetectionService } from "@maple/backend/services/alerts/AnomalyDetectionService" import { ErrorsService } from "@maple/backend/services/errors/ErrorsService" import { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService" @@ -48,6 +49,7 @@ import { HttpV2AlertDeliveriesLive } from "./alert-deliveries.http" import { HttpV2AlertDestinationsLive } from "./alert-destinations.http" import { HttpV2AlertIncidentsLive } from "./alert-incidents.http" import { HttpV2EscalationChainsLive } from "./escalation-chains.http" +import { HttpV2OnCallSchedulesLive } from "./oncall-schedules.http" import { HttpV2AlertRulesLive } from "./alert-rules.http" import { HttpV2ApiKeysLive } from "./api-keys.http" import { HttpV2AttributeMappingsLive } from "./attribute-mappings.http" @@ -193,6 +195,14 @@ const v2GroupLayersExceptOnboardingChecklist = ( ), // Real service: it needs only the Database every harness already provides. HttpV2EscalationChainsLive.pipe(Layer.provide(EscalationChainsService.layer)), + // Real service; the empty member directory accepts every participant. + HttpV2OnCallSchedulesLive.pipe( + Layer.provide( + Layer.effect(OnCallSchedulesService, OnCallSchedulesService.make).pipe( + Layer.provide(OrgMembersServiceStubLayer), + ), + ), + ), HttpV2IngestKeysLive, HttpV2SourcesLive, HttpV2ErrorIssuesLive, diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index d09616a745..7336f9936e 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -46,6 +46,7 @@ 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 { HttpV2EscalationChainsLive } from "@/routes/v2/escalation-chains.http" +import { HttpV2OnCallSchedulesLive } from "@/routes/v2/oncall-schedules.http" import { HttpV2AlertRulesLive } from "@/routes/v2/alert-rules.http" import { HttpV2AnomaliesLive } from "@/routes/v2/anomalies.http" import { HttpV2ApiKeysLive } from "@/routes/v2/api-keys.http" @@ -185,6 +186,7 @@ const ApiV2Routes = HttpApiBuilder.layer(MapleApiV2).pipe( HttpV2AlertDestinationsLive, HttpV2AlertIncidentsLive, HttpV2EscalationChainsLive, + HttpV2OnCallSchedulesLive, HttpV2IngestKeysLive, HttpV2SourcesLive, HttpV2ChatIntegrationsLive, diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index 7e54f623d1..9ce5186d01 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -12,6 +12,7 @@ import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertRead import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" +import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" import { AnomalyDetectionService } from "@maple/backend/services/alerts/AnomalyDetectionService" import { PlanetScaleOAuthService } from "@maple/backend/services/auth/PlanetScaleOAuthService" @@ -183,6 +184,7 @@ export const HttpServicesLive = Layer.mergeAll( AlertRulesService.layer, AlertsService.layer, EscalationChainsService.layer, + OnCallSchedulesService.layer, AnomalyDetectionService.layer, AiTriageService.layer, InvestigationService.layer, diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index 3431531215..1afb3249f1 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -14073,6 +14073,10 @@ "description": "Step-by-step paging for alert incidents: notify the first responder, wait for an acknowledgement, then widen the circle. A rule's severity tier names the chain it starts.", "name": "Escalation Chains" }, + { + "description": "Who is on call when: layered rotations in a time zone, with overrides for swaps and cover.", + "name": "On-Call Schedules" + }, { "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" diff --git a/apps/landing/src/content/docs/reference/mcp.md b/apps/landing/src/content/docs/reference/mcp.md index 34a6d3f378..b0a06e170e 100644 --- a/apps/landing/src/content/docs/reference/mcp.md +++ b/apps/landing/src/content/docs/reference/mcp.md @@ -118,6 +118,13 @@ Tools that take an alert rule, alert destination, dashboard or error issue ID ac | `create_escalation_chain` | write | Page destinations step by step, waiting for an acknowledgement between steps | | `update_escalation_chain` | write | Change a chain's name, steps, repeats or description | | `delete_escalation_chain` | write | Delete a chain no rule uses | +| `list_oncall_schedules` | read | On-call schedules with their layers and who is on call now | +| `who_is_on_call` | read | Who is on call for a schedule at an instant, why, and who is next | +| `create_oncall_schedule` | write | Rotate workspace members daily, weekly or every N hours, in layers | +| `update_oncall_schedule` | write | Change a schedule's name, time zone, layers or description | +| `delete_oncall_schedule` | write | Delete a schedule and its overrides | +| `create_oncall_override` | write | Put one member on call for a window, over every layer | +| `delete_oncall_override` | write | Delete an override so the layers decide again | ### 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 5b893504fd..165e4fc6c5 100644 --- a/apps/web/src/page/investigations/conversation/kit/tool-metadata.ts +++ b/apps/web/src/page/investigations/conversation/kit/tool-metadata.ts @@ -68,6 +68,13 @@ const TOOL_LABELS = new Map([ ["create_escalation_chain", "Create Escalation Chain"], ["update_escalation_chain", "Update Escalation Chain"], ["delete_escalation_chain", "Delete Escalation Chain"], + ["list_oncall_schedules", "List On-Call Schedules"], + ["who_is_on_call", "Who Is On Call"], + ["create_oncall_schedule", "Create On-Call Schedule"], + ["update_oncall_schedule", "Update On-Call Schedule"], + ["delete_oncall_schedule", "Delete On-Call Schedule"], + ["create_oncall_override", "Create On-Call Override"], + ["delete_oncall_override", "Delete On-Call Override"], ["get_error_notification_policy", "Notification Policy"], ["update_error_notification_policy", "Notification Policy"], // sessions @@ -141,6 +148,13 @@ const TOOL_ICONS = new Map([ ["create_escalation_chain", "BellIcon"], ["update_escalation_chain", "BellIcon"], ["delete_escalation_chain", "TrashIcon"], + ["list_oncall_schedules", "BellIcon"], + ["who_is_on_call", "BellIcon"], + ["create_oncall_schedule", "BellIcon"], + ["update_oncall_schedule", "BellIcon"], + ["delete_oncall_schedule", "TrashIcon"], + ["create_oncall_override", "BellIcon"], + ["delete_oncall_override", "TrashIcon"], ["get_error_notification_policy", "BellIcon"], ["update_error_notification_policy", "BellIcon"], ["run_sql", "DatabaseIcon"], diff --git a/packages/domain/src/audit-actions.ts b/packages/domain/src/audit-actions.ts index acea4c1487..c45e18ebe0 100644 --- a/packages/domain/src/audit-actions.ts +++ b/packages/domain/src/audit-actions.ts @@ -71,6 +71,14 @@ export const AuditResources = { prefix: PublicIdPrefixes.escalationChain, verbs: ["created", "updated", "deleted"], }, + oncall_schedule: { + prefix: PublicIdPrefixes.oncallSchedule, + verbs: ["created", "updated", "deleted"], + }, + oncall_override: { + prefix: PublicIdPrefixes.oncallOverride, + verbs: ["created", "deleted"], + }, /** One connected Google Cloud scope; the scope and what it collects are in `metadata`. */ gcp_connector: { prefix: PublicIdPrefixes.gcpConnector, @@ -193,6 +201,8 @@ export const AuditResourceLabels = { alert_destination: "Alert destination", alert_rule: "Alert rule", escalation_chain: "Escalation chain", + oncall_schedule: "On-call schedule", + oncall_override: "On-call override", anomaly_incident: "Anomaly", anomaly_settings: "Anomaly detection", api: "API", @@ -247,6 +257,11 @@ export const AuditActionLabels = { "escalation_chain.created": "Created escalation chain", "escalation_chain.updated": "Updated escalation chain", "escalation_chain.deleted": "Deleted escalation chain", + "oncall_schedule.created": "Created on-call schedule", + "oncall_schedule.updated": "Updated on-call schedule", + "oncall_schedule.deleted": "Deleted on-call schedule", + "oncall_override.created": "Created on-call override", + "oncall_override.deleted": "Deleted on-call override", "anomaly_incident.resolved": "Resolved anomaly", "anomaly_settings.updated": "Updated anomaly detection", "api.request": "Made API request", diff --git a/packages/domain/src/generated/anticipated-error-identifiers.ts b/packages/domain/src/generated/anticipated-error-identifiers.ts index 0ccb799485..cb9199ad0d 100644 --- a/packages/domain/src/generated/anticipated-error-identifiers.ts +++ b/packages/domain/src/generated/anticipated-error-identifiers.ts @@ -87,6 +87,9 @@ export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray = [ "@maple/http/errors/McpOAuthAuthorizationExpiredError", "@maple/http/errors/McpOAuthAuthorizationNotFoundError", "@maple/http/errors/MobileDeviceNotFoundError", + "@maple/http/errors/OnCallOverrideNotFoundError", + "@maple/http/errors/OnCallScheduleInUseError", + "@maple/http/errors/OnCallScheduleNotFoundError", "@maple/http/errors/OrgClickHouseSettingsForbiddenError", "@maple/http/errors/OrgClickHouseSettingsUpstreamRejectedError", "@maple/http/errors/OrgClickHouseSettingsValidationError", diff --git a/packages/domain/src/http/v2/api.ts b/packages/domain/src/http/v2/api.ts index 33a4c68f66..1ec1f0023f 100644 --- a/packages/domain/src/http/v2/api.ts +++ b/packages/domain/src/http/v2/api.ts @@ -4,6 +4,7 @@ import { V2AlertDestinationsApiGroup } from "./alert-destinations" import { V2AnomaliesApiGroup } from "./anomalies" import { V2AlertIncidentsApiGroup } from "./alert-incidents" import { V2EscalationChainsApiGroup } from "./escalation-chains" +import { V2OnCallSchedulesApiGroup } from "./oncall-schedules" import { V2AlertRulesApiGroup } from "./alert-rules" import { V2ApiKeysApiGroup } from "./api-keys" import { V2AttributeMappingsApiGroup } from "./attribute-mappings" @@ -107,6 +108,7 @@ export class MapleApiV2 extends HttpApi.make("MapleApiV2") .add(V2AlertDestinationsApiGroup) .add(V2AlertIncidentsApiGroup) .add(V2EscalationChainsApiGroup) + .add(V2OnCallSchedulesApiGroup) .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 135fc8ff3f..532b1420cc 100644 --- a/packages/domain/src/http/v2/index.ts +++ b/packages/domain/src/http/v2/index.ts @@ -14,6 +14,7 @@ export * from "./envelopes" export * from "./errors" export * from "./error-issues" export * from "./escalation-chains" +export * from "./oncall-schedules" export * from "./ingest-keys" export * from "./sources" export * from "./integrations-chat" diff --git a/packages/domain/src/http/v2/oncall-schedules.ts b/packages/domain/src/http/v2/oncall-schedules.ts new file mode 100644 index 0000000000..e5c991f6ff --- /dev/null +++ b/packages/domain/src/http/v2/oncall-schedules.ts @@ -0,0 +1,676 @@ +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api" +import { Array as Arr, DateTime, Schema } from "effect" +import { + AlertForbiddenError, + AlertMemberDirectoryNotConfiguredError, + AlertMemberDirectoryUnavailableError, + AlertPersistenceError, + AlertValidationError, +} from "../alerts" +import { + MAX_ONCALL_LAYERS, + type OnCallLayer, + type OnCallRestriction, + type OnCallRotation, + MAX_ONCALL_PARTICIPANTS, + MAX_ONCALL_RESTRICTIONS, + OnCallLocalTime, + OnCallOverrideNotFoundError, + OnCallScheduleDescription, + OnCallScheduleInUseError, + OnCallScheduleName, + OnCallScheduleNotFoundError, + OnCallTimeZone, + OnCallWeekday, +} from "../oncall-schedules" +import { UserId } from "../../primitives" +import { AuthorizationV2 } from "./auth" +import { isoTimestamp, wireExample, ListOf, ListQuery, Timestamp } from "./envelopes" +import { V2ParameterInvalid } from "./errors" +import { publicErrors } from "./public-error" +import { EscalationChainPublicId, OnCallOverridePublicId, OnCallSchedulePublicId } from "./resource-ids" + +export { OnCallOverridePublicId, OnCallSchedulePublicId } from "./resource-ids" + +const ScheduleUserId = UserId.annotate({ + description: "A Maple user id (`user_…`) of a member of your organization.", +}) + +const HandoffTime = OnCallLocalTime.annotate({ + description: "Local wall-clock time of the handoff in the schedule's time zone, `HH:MM`.", + examples: ["09:00"], +}) + +export const V2OnCallRotation = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("daily").annotate({ description: "Hand off every day at `handoff_time`." }), + handoff_time: HandoffTime, + }), + Schema.Struct({ + kind: Schema.Literal("weekly").annotate({ + description: "Hand off once a week on `handoff_weekday` at `handoff_time`.", + }), + handoff_weekday: OnCallWeekday, + handoff_time: HandoffTime, + }), + Schema.Struct({ + kind: Schema.Literal("custom").annotate({ + description: "Fixed-length shifts of `length_hours` elapsed hours, from the layer's `starts_at`.", + }), + length_hours: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 336 })).annotate({ + description: "Shift length in hours, 1 to 336.", + examples: [12], + }), + }), +]).annotate({ + identifier: "OnCallRotation", + title: "On-call rotation", + description: "When the layer hands off from one participant to the next.", +}) +export type V2OnCallRotation = Schema.Schema.Type + +export const V2OnCallRestriction = Schema.Struct({ + weekdays: Schema.Array(OnCallWeekday) + .pipe(Schema.refine(Arr.isReadonlyArrayNonEmpty)) + .check(Schema.isMinLength(1), Schema.isMaxLength(7)) + .annotate({ description: "Days the window starts on." }), + start_time: OnCallLocalTime.annotate({ description: "Local start time, `HH:MM`.", examples: ["18:00"] }), + end_time: OnCallLocalTime.annotate({ + description: "Local end time, `HH:MM`. At or before `start_time` crosses midnight.", + examples: ["08:00"], + }), +}).annotate({ + identifier: "OnCallRestriction", + title: "On-call restriction", + description: "A weekly local window the layer is active in.", +}) +export type V2OnCallRestriction = Schema.Schema.Type + +export const V2OnCallLayer = Schema.Struct({ + name: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(100)).annotate({ + description: "Display name, 1 to 100 characters.", + examples: ["Primary"], + }), + participants: Schema.Array(ScheduleUserId) + .pipe(Schema.refine(Arr.isReadonlyArrayNonEmpty)) + .check(Schema.isMinLength(1), Schema.isMaxLength(MAX_ONCALL_PARTICIPANTS)) + .annotate({ + description: `Who rotates through the layer, in order: 1 to ${MAX_ONCALL_PARTICIPANTS} members.`, + }), + rotation: V2OnCallRotation, + starts_at: Timestamp.annotate({ + description: "Rotation anchor: the first participant holds the shift that contains this instant.", + }), + restrictions: Schema.optionalKey( + Schema.Array(V2OnCallRestriction) + .check(Schema.isMaxLength(MAX_ONCALL_RESTRICTIONS)) + .annotate({ + description: `Weekly windows the layer is limited to, at most ${MAX_ONCALL_RESTRICTIONS}. Omitted or empty means always active.`, + }), + ), +}).annotate({ + identifier: "OnCallLayer", + title: "On-call layer", + description: "One rotation. Later layers sit on top of earlier ones wherever they have someone on call.", +}) +export type V2OnCallLayer = Schema.Schema.Type + +export const V2OnCallLayers = Schema.Array(V2OnCallLayer) + .pipe(Schema.refine(Arr.isReadonlyArrayNonEmpty)) + .check(Schema.isMinLength(1), Schema.isMaxLength(MAX_ONCALL_LAYERS)) + .annotate({ description: `The layers, bottom first: 1 to ${MAX_ONCALL_LAYERS}.` }) + +const toV2Rotation = (rotation: OnCallRotation): V2OnCallRotation => { + switch (rotation.kind) { + case "daily": + return { kind: "daily", handoff_time: rotation.handoffTime } + case "weekly": + return { + kind: "weekly", + handoff_weekday: rotation.handoffWeekday, + handoff_time: rotation.handoffTime, + } + case "custom": + return { kind: "custom", length_hours: rotation.lengthHours } + } +} + +const fromV2Rotation = (rotation: V2OnCallRotation): OnCallRotation => { + switch (rotation.kind) { + case "daily": + return { kind: "daily", handoffTime: rotation.handoff_time } + case "weekly": + return { + kind: "weekly", + handoffWeekday: rotation.handoff_weekday, + handoffTime: rotation.handoff_time, + } + case "custom": + return { kind: "custom", lengthHours: rotation.length_hours } + } +} + +const toV2Restriction = (r: OnCallRestriction): V2OnCallRestriction => ({ + weekdays: r.weekdays, + start_time: r.startTime, + end_time: r.endTime, +}) + +const fromV2Restriction = (r: V2OnCallRestriction): OnCallRestriction => ({ + weekdays: r.weekdays, + startTime: r.start_time, + endTime: r.end_time, +}) + +/** A decoded wire timestamp as epoch ms; the schema already proved it parses. */ +export const v2TimestampMs = (value: Timestamp): number => DateTime.toEpochMillis(DateTime.makeUnsafe(value)) + +export const toV2OnCallLayer = (layer: OnCallLayer): V2OnCallLayer => ({ + name: layer.name, + participants: layer.participants, + rotation: toV2Rotation(layer.rotation), + starts_at: isoTimestamp(layer.startsAt), + ...(layer.restrictions === undefined + ? undefined + : { restrictions: Arr.map(layer.restrictions, toV2Restriction) }), +}) + +export const fromV2OnCallLayer = (layer: V2OnCallLayer): OnCallLayer => ({ + name: layer.name, + participants: layer.participants, + rotation: fromV2Rotation(layer.rotation), + startsAt: v2TimestampMs(layer.starts_at), + ...(layer.restrictions === undefined + ? undefined + : { restrictions: Arr.map(layer.restrictions, fromV2Restriction) }), +}) + +const TimeZone = OnCallTimeZone.annotate({ + description: "IANA time zone that handoffs and restrictions are read in.", + examples: ["Europe/Berlin"], +}) + +export const V2OnCallNow = Schema.Struct({ + user_id: UserId, + starts_at: Timestamp, + ends_at: Timestamp, +}).annotate({ + identifier: "OnCallNow", + title: "On call now", + description: "Who holds the current shift, with its bounds (looked up at most 15 days either side).", +}) + +const scheduleExample = { + id: "onc_gQtGH2s65G6a7sJmuayXL6", + object: "oncall_schedule", + name: "Checkout primary", + description: null, + time_zone: "Europe/Berlin", + layers: [ + { + name: "Primary", + participants: ["user_2Nk8mXqPfR3yZ1aB4cD5eF6g", "user_2Pq9rStUvWxYz0aB1cD2eF3g"], + rotation: { kind: "weekly", handoff_weekday: "mon", handoff_time: "09:00" }, + starts_at: "2026-11-09T08:00:00.000Z", + }, + ], + on_call_now: { + user_id: "user_2Nk8mXqPfR3yZ1aB4cD5eF6g", + starts_at: "2026-11-09T08:00:00.000Z", + ends_at: "2026-11-16T08:00:00.000Z", + }, + chain_ids: [], + created_at: "2026-11-06T09:00:00.000Z", + updated_at: "2026-11-06T09:00:00.000Z", +} as const + +export const V2OnCallSchedule = Schema.Struct({ + id: OnCallSchedulePublicId, + object: Schema.Literal("oncall_schedule").annotate({ + description: 'The object type, always `"oncall_schedule"`.', + examples: ["oncall_schedule"], + }), + name: Schema.String.annotate({ description: "Display name.", examples: ["Checkout primary"] }), + description: Schema.NullOr(Schema.String).annotate({ description: "Free-form notes, or `null`." }), + time_zone: Schema.String.annotate({ description: "IANA time zone.", examples: ["Europe/Berlin"] }), + layers: V2OnCallLayers, + on_call_now: Schema.NullOr(V2OnCallNow).annotate({ + description: "Who is on call right now, or `null` when nobody is.", + }), + chain_ids: Schema.Array(EscalationChainPublicId).annotate({ + description: "Escalation chains (`esc_…`) that page this schedule.", + }), + created_at: Timestamp, + updated_at: Timestamp, +}).annotate({ + identifier: "OnCallSchedule", + title: "On-Call Schedule", + description: + "Who is on call when. Each layer rotates its participants; later layers win where they have someone on call, and overrides beat every layer.", + examples: [wireExample(scheduleExample)], +}) +export type V2OnCallSchedule = Schema.Schema.Type + +export const V2OnCallScheduleCreateParams = Schema.Struct({ + name: OnCallScheduleName.annotate({ description: "Display name, 1 to 120 characters." }), + description: Schema.optionalKey( + Schema.NullOr(OnCallScheduleDescription).annotate({ + description: "Free-form notes, at most 500 characters.", + }), + ), + time_zone: TimeZone, + layers: V2OnCallLayers, +}).annotate({ + identifier: "OnCallScheduleCreateParams", + title: "Create on-call schedule", + description: "Every participant must be a member of your organization.", + examples: [ + wireExample({ + name: scheduleExample.name, + time_zone: scheduleExample.time_zone, + layers: scheduleExample.layers, + }), + ], +}) +export type V2OnCallScheduleCreateParams = Schema.Schema.Type + +export const V2OnCallScheduleUpdateParams = Schema.Struct({ + name: Schema.optionalKey(OnCallScheduleName), + description: Schema.optionalKey(Schema.NullOr(OnCallScheduleDescription)), + time_zone: Schema.optionalKey(TimeZone), + layers: Schema.optionalKey(V2OnCallLayers.annotate({ description: "Replaces every layer." })), +}).annotate({ + identifier: "OnCallScheduleUpdateParams", + title: "Update on-call schedule", + description: "Omitted fields are unchanged. Overrides are kept.", + examples: [wireExample({ time_zone: "America/New_York" })], +}) +export type V2OnCallScheduleUpdateParams = Schema.Schema.Type + +export const V2OnCallScheduleDeleteResponse = Schema.Struct({ + id: OnCallSchedulePublicId, + object: Schema.Literal("oncall_schedule").annotate({ + description: 'The object type, always `"oncall_schedule"`.', + }), + deleted: Schema.Literal(true).annotate({ description: "Always `true`: the schedule no longer exists." }), +}).annotate({ + identifier: "OnCallScheduleDeleteResponse", + title: "On-call schedule delete response", + description: "Confirmation that an on-call schedule and its overrides were deleted.", + examples: [wireExample({ id: scheduleExample.id, object: "oncall_schedule", deleted: true })], +}) +export type V2OnCallScheduleDeleteResponse = Schema.Schema.Type + +export const V2OnCallShift = Schema.Struct({ + user_id: UserId, + starts_at: Timestamp, + ends_at: Timestamp, + source: Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("override"), + override_id: OnCallOverridePublicId, + }), + Schema.Struct({ + kind: Schema.Literal("layer"), + layer_index: Schema.Number.annotate({ description: "Index into `layers`, from 0." }), + }), + ]).annotate({ description: "What put this person on call: an override or a layer." }), +}).annotate({ + identifier: "OnCallShift", + title: "On-call shift", + description: "One person on call over a time range.", +}) +export type V2OnCallShift = Schema.Schema.Type + +const shiftExample = { + user_id: "user_2Nk8mXqPfR3yZ1aB4cD5eF6g", + starts_at: "2026-11-09T08:00:00.000Z", + ends_at: "2026-11-16T08:00:00.000Z", + source: { kind: "layer", layer_index: 0 }, +} as const + +export const V2OnCallAt = Schema.Struct({ + object: Schema.Literal("oncall").annotate({ description: 'Always `"oncall"`.' }), + schedule_id: OnCallSchedulePublicId, + at: Timestamp.annotate({ description: "The instant asked about." }), + shift: Schema.NullOr(V2OnCallShift).annotate({ + description: + "The effective shift containing `at`, bounds clamped to 15 days either side, or `null` when nobody is on call.", + }), +}).annotate({ + identifier: "OnCallAt", + title: "Who is on call", + description: "Who is on call for a schedule at one instant.", + examples: [ + wireExample({ + object: "oncall", + schedule_id: scheduleExample.id, + at: "2026-11-12T19:00:00.000Z", + shift: shiftExample, + }), + ], +}) +export type V2OnCallAt = Schema.Schema.Type + +export const V2OnCallShifts = Schema.Struct({ + object: Schema.Literal("oncall_shifts").annotate({ description: 'Always `"oncall_shifts"`.' }), + schedule_id: OnCallSchedulePublicId, + from: Timestamp, + to: Timestamp.annotate({ description: "The range end, at most 90 days after `from`." }), + layers: Schema.Array(Schema.Array(V2OnCallShift)).annotate({ + description: "Each layer's own shifts after its restrictions, in layer order.", + }), + final: Schema.Array(V2OnCallShift).annotate({ + description: "The effective schedule after layering and overrides. Gaps are times nobody is on call.", + }), +}).annotate({ + identifier: "OnCallShifts", + title: "On-call shifts", + description: "A schedule rendered over a time range.", + examples: [ + wireExample({ + object: "oncall_shifts", + schedule_id: scheduleExample.id, + from: "2026-11-09T08:00:00.000Z", + to: "2026-11-16T08:00:00.000Z", + layers: [[shiftExample]], + final: [shiftExample], + }), + ], +}) +export type V2OnCallShifts = Schema.Schema.Type + +const overrideExample = { + id: "ovr_aMadCYFsFjn8nerC3aEWyG", + object: "oncall_override", + schedule_id: scheduleExample.id, + user_id: "user_2Pq9rStUvWxYz0aB1cD2eF3g", + starts_at: "2026-11-12T17:00:00.000Z", + ends_at: "2026-11-13T08:00:00.000Z", + created_by: "user_2Nk8mXqPfR3yZ1aB4cD5eF6g", + created_at: "2026-11-10T09:00:00.000Z", +} as const + +export const V2OnCallOverride = Schema.Struct({ + id: OnCallOverridePublicId, + object: Schema.Literal("oncall_override").annotate({ + description: 'The object type, always `"oncall_override"`.', + examples: ["oncall_override"], + }), + schedule_id: OnCallSchedulePublicId, + user_id: UserId.annotate({ description: "Who covers the window." }), + starts_at: Timestamp, + ends_at: Timestamp, + created_by: Schema.String.annotate({ description: "Who created the override." }), + created_at: Timestamp, +}).annotate({ + identifier: "OnCallOverride", + title: "On-Call Override", + description: + "One person covering a schedule for a window. Beats every layer; the newest overlapping override wins.", + examples: [wireExample(overrideExample)], +}) +export type V2OnCallOverride = Schema.Schema.Type + +export const V2OnCallOverrideCreateParams = Schema.Struct({ + user_id: ScheduleUserId, + starts_at: Timestamp, + ends_at: Timestamp.annotate({ + description: "After `starts_at`, in the future, and at most 90 days after `starts_at`.", + }), +}).annotate({ + identifier: "OnCallOverrideCreateParams", + title: "Create on-call override", + description: "The user must be a member of your organization.", + examples: [ + wireExample({ + user_id: overrideExample.user_id, + starts_at: overrideExample.starts_at, + ends_at: overrideExample.ends_at, + }), + ], +}) +export type V2OnCallOverrideCreateParams = Schema.Schema.Type + +export const V2OnCallOverrideDeleteResponse = Schema.Struct({ + id: OnCallOverridePublicId, + object: Schema.Literal("oncall_override").annotate({ + description: 'The object type, always `"oncall_override"`.', + }), + deleted: Schema.Literal(true).annotate({ description: "Always `true`: the override no longer exists." }), +}).annotate({ + identifier: "OnCallOverrideDeleteResponse", + title: "On-call override delete response", + description: "Confirmation that an override was deleted.", + examples: [wireExample({ id: overrideExample.id, object: "oncall_override", deleted: true })], +}) +export type V2OnCallOverrideDeleteResponse = Schema.Schema.Type + +export const V2OnCallAtQuery = Schema.Struct({ + at: Schema.optional(Timestamp.annotate({ description: "The instant to ask about. Defaults to now." })), +}).annotate({ identifier: "OnCallAtQuery", title: "Who is on call query" }) + +export const V2OnCallShiftsQuery = Schema.Struct({ + from: Schema.optional(Timestamp.annotate({ description: "Range start. Defaults to now." })), + to: Schema.optional( + Timestamp.annotate({ + description: "Range end, after `from`. Defaults to 14 days after `from`; clamped to 90 days.", + }), + ), +}).annotate({ identifier: "OnCallShiftsQuery", title: "On-call shifts query" }) + +export const V2OnCallOverridesQuery = Schema.Struct({ + ...ListQuery.fields, + from: Schema.optional( + Timestamp.annotate({ description: "Only overrides that end after this time. Defaults to now." }), + ), +}).annotate({ identifier: "OnCallOverridesQuery", title: "On-call overrides query" }) + +const OnCallScheduleList = ListOf(V2OnCallSchedule).annotate({ + identifier: "OnCallScheduleList", + title: "On-call schedule list", + description: "A cursor-paginated page of on-call schedules, by name.", +}) + +const OnCallOverrideList = ListOf(V2OnCallOverride).annotate({ + identifier: "OnCallOverrideList", + title: "On-call override list", + description: "A cursor-paginated page of overrides, by start time.", +}) + +const [ + alertForbidden, + alertValidation, + alertPersistence, + directoryNotConfigured, + directoryUnavailable, + scheduleNotFound, + overrideNotFound, + scheduleInUse, +] = publicErrors( + AlertForbiddenError, + AlertValidationError, + AlertPersistenceError, + AlertMemberDirectoryNotConfiguredError, + AlertMemberDirectoryUnavailableError, + OnCallScheduleNotFoundError, + OnCallOverrideNotFoundError, + OnCallScheduleInUseError, +) + +const scheduleId = { id: OnCallSchedulePublicId } + +export class V2OnCallSchedulesApiGroup extends HttpApiGroup.make("oncallSchedules") + .add( + HttpApiEndpoint.get("list", "/", { + query: ListQuery, + success: OnCallScheduleList, + error: [V2ParameterInvalid.schema, alertPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "listOnCallSchedules", + summary: "List on-call schedules", + description: + "Returns your organization's on-call schedules by name, each with who is on call now. Requires the `alerts:read` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.get("retrieve", "/:id", { + params: scheduleId, + success: V2OnCallSchedule, + error: [scheduleNotFound, alertPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "getOnCallSchedule", + summary: "Retrieve an on-call schedule", + description: "Returns a single schedule by its `onc_…` ID. Requires the `alerts:read` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.post("create", "/", { + payload: V2OnCallScheduleCreateParams, + success: V2OnCallSchedule, + error: [ + alertForbidden, + alertValidation, + alertPersistence, + directoryNotConfigured, + directoryUnavailable, + ], + }).annotateMerge( + OpenApi.annotations({ + identifier: "createOnCallSchedule", + summary: "Create an on-call schedule", + description: + "Creates a schedule. Fails with an `invalid_request_error` when a participant is not a member of your organization, or at 50 schedules. Requires a role that can manage alerts and the `alerts:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.patch("update", "/:id", { + params: scheduleId, + payload: V2OnCallScheduleUpdateParams, + success: V2OnCallSchedule, + error: [ + alertForbidden, + alertValidation, + alertPersistence, + directoryNotConfigured, + directoryUnavailable, + scheduleNotFound, + ], + }).annotateMerge( + OpenApi.annotations({ + identifier: "updateOnCallSchedule", + summary: "Update an on-call schedule", + description: + "Changes the name, description, time zone or layers. Requires the `alerts:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("delete", "/:id", { + params: scheduleId, + success: V2OnCallScheduleDeleteResponse, + error: [alertForbidden, alertPersistence, scheduleNotFound, scheduleInUse], + }).annotateMerge( + OpenApi.annotations({ + identifier: "deleteOnCallSchedule", + summary: "Delete an on-call schedule", + description: + "Deletes a schedule and its overrides. Fails with a `conflict_error` while an escalation chain pages it. Requires the `alerts:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.get("oncall", "/:id/oncall", { + params: scheduleId, + query: V2OnCallAtQuery, + success: V2OnCallAt, + error: [V2ParameterInvalid.schema, scheduleNotFound, alertPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "getOnCall", + summary: "Who is on call", + description: + "Returns who is on call for the schedule at `at` (default now), with the shift's bounds. Requires the `alerts:read` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.get("shifts", "/:id/shifts", { + params: scheduleId, + query: V2OnCallShiftsQuery, + success: V2OnCallShifts, + error: [V2ParameterInvalid.schema, alertValidation, scheduleNotFound, alertPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "listOnCallShifts", + summary: "Render on-call shifts", + description: + "Renders the schedule over `from` to `to`: each layer's shifts and the effective shifts after overrides. Ranges past 90 days are cut to 90. Requires the `alerts:read` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.get("listOverrides", "/:id/overrides", { + params: scheduleId, + query: V2OnCallOverridesQuery, + success: OnCallOverrideList, + error: [V2ParameterInvalid.schema, scheduleNotFound, alertPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "listOnCallOverrides", + summary: "List on-call overrides", + description: + "Returns the schedule's overrides that end after `from` (default now), by start time. Requires the `alerts:read` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.post("createOverride", "/:id/overrides", { + params: scheduleId, + payload: V2OnCallOverrideCreateParams, + success: V2OnCallOverride, + error: [ + alertForbidden, + alertValidation, + alertPersistence, + directoryNotConfigured, + directoryUnavailable, + scheduleNotFound, + ], + }).annotateMerge( + OpenApi.annotations({ + identifier: "createOnCallOverride", + summary: "Create an on-call override", + description: + "Puts one member on call for a window, over every layer. At most 90 days long, not entirely in the past, and 500 upcoming overrides per schedule. Requires the `alerts:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("deleteOverride", "/:id/overrides/:override_id", { + params: { id: OnCallSchedulePublicId, override_id: OnCallOverridePublicId }, + success: V2OnCallOverrideDeleteResponse, + error: [alertForbidden, alertPersistence, scheduleNotFound, overrideNotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "deleteOnCallOverride", + summary: "Delete an on-call override", + description: + "Deletes an override; the layers decide again. Requires the `alerts:write` scope.", + }), + ), + ) + .prefix("/v2/alerts/oncall_schedules") + .middleware(AuthorizationV2) + .annotateMerge( + OpenApi.annotations({ + title: "On-Call Schedules", + description: + "Who is on call when: layered rotations in a time zone, with overrides for swaps and cover.", + }), + ) {} diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index 9c426d97c0..6e20549775 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -82,6 +82,8 @@ describe("MapleApiV2 OpenAPI", () => { expect(surface).toEqual([ "DELETE /v2/alerts/destinations/{id}", "DELETE /v2/alerts/escalation_chains/{id}", + "DELETE /v2/alerts/oncall_schedules/{id}", + "DELETE /v2/alerts/oncall_schedules/{id}/overrides/{override_id}", "DELETE /v2/alerts/rules/{id}", "DELETE /v2/api_keys/{id}", "DELETE /v2/attribute_mappings/{id}", @@ -116,6 +118,11 @@ describe("MapleApiV2 OpenAPI", () => { "GET /v2/alerts/incidents", "GET /v2/alerts/incidents/{id}", "GET /v2/alerts/incidents/{id}/events", + "GET /v2/alerts/oncall_schedules", + "GET /v2/alerts/oncall_schedules/{id}", + "GET /v2/alerts/oncall_schedules/{id}/oncall", + "GET /v2/alerts/oncall_schedules/{id}/overrides", + "GET /v2/alerts/oncall_schedules/{id}/shifts", "GET /v2/alerts/rules", "GET /v2/alerts/rules/{id}", "GET /v2/alerts/rules/{id}/checks", @@ -206,6 +213,7 @@ describe("MapleApiV2 OpenAPI", () => { "GET /v2/widget_summary", "PATCH /v2/alerts/destinations/{id}", "PATCH /v2/alerts/escalation_chains/{id}", + "PATCH /v2/alerts/oncall_schedules/{id}", "PATCH /v2/alerts/rules/{id}", "PATCH /v2/anomalies/settings", "PATCH /v2/attribute_mappings/{id}", @@ -228,6 +236,8 @@ describe("MapleApiV2 OpenAPI", () => { "POST /v2/alerts/escalation_chains", "POST /v2/alerts/incidents/{id}/acknowledge", "POST /v2/alerts/incidents/{id}/unacknowledge", + "POST /v2/alerts/oncall_schedules", + "POST /v2/alerts/oncall_schedules/{id}/overrides", "POST /v2/alerts/rules", "POST /v2/alerts/rules/preview", "POST /v2/alerts/rules/test", diff --git a/packages/domain/src/http/v2/public-id.ts b/packages/domain/src/http/v2/public-id.ts index c706c92489..fd7d77887d 100644 --- a/packages/domain/src/http/v2/public-id.ts +++ b/packages/domain/src/http/v2/public-id.ts @@ -37,6 +37,8 @@ export const PublicIdPrefixes = { alertDestination: "dest", alertIncident: "inc", escalationChain: "esc", + oncallSchedule: "onc", + oncallOverride: "ovr", actor: "actor", auditLogEntry: "alog", errorIssue: "iss", diff --git a/packages/domain/src/http/v2/resource-ids.ts b/packages/domain/src/http/v2/resource-ids.ts index df01f47218..eecc6824a5 100644 --- a/packages/domain/src/http/v2/resource-ids.ts +++ b/packages/domain/src/http/v2/resource-ids.ts @@ -11,6 +11,8 @@ import { ErrorIncidentId, ErrorIssueId, EscalationChainId, + OnCallOverrideId, + OnCallScheduleId, InvestigationId, McpConnectionId, MobileDeviceId, @@ -24,6 +26,8 @@ export const AlertIncidentPublicId = PublicId(PublicIdPrefixes.alertIncident, Al export const AlertDeliveryEventPublicId = PublicId(PublicIdPrefixes.event, AlertDeliveryEventId) export const AlertIncidentEventPublicId = PublicId(PublicIdPrefixes.event, AlertIncidentEventId) export const EscalationChainPublicId = PublicId(PublicIdPrefixes.escalationChain, EscalationChainId) +export const OnCallSchedulePublicId = PublicId(PublicIdPrefixes.oncallSchedule, OnCallScheduleId) +export const OnCallOverridePublicId = PublicId(PublicIdPrefixes.oncallOverride, OnCallOverrideId) export const ActorPublicId = PublicId(PublicIdPrefixes.actor, ActorId) export const AnomalyIncidentPublicId = PublicId(PublicIdPrefixes.anomalyIncident, AnomalyIncidentId) export const ErrorIncidentPublicId = PublicId(PublicIdPrefixes.errorIncident, ErrorIncidentId) diff --git a/packages/domain/src/http/v2/v2-contract.test.ts b/packages/domain/src/http/v2/v2-contract.test.ts index 0d97ae339e..562a5c14f1 100644 --- a/packages/domain/src/http/v2/v2-contract.test.ts +++ b/packages/domain/src/http/v2/v2-contract.test.ts @@ -8,6 +8,7 @@ import { V2ApiKey, V2ApiKeyMutationResponse, V2ApiKeyWithSecret } from "./api-ke import { V2DashboardMutation } from "./dashboards" import { V2ErrorIssue } from "./error-issues" import { V2EscalationChain } from "./escalation-chains" +import { V2OnCallOverride, V2OnCallSchedule, V2OnCallShift } from "./oncall-schedules" import { AuthorizationV2, isScopeExemptRoute, requiredScopeForRoute, scopeAllows, V2Scope } from "./auth" import { V2PlanetScaleIntegration, @@ -474,6 +475,76 @@ describe("V2 alerts wire format", () => { ).toThrow() }) + it("encodes an on-call schedule with onc_ ids, snake_case layers and Maple user ids", () => { + const SCHEDULE_UUID = "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d" + const scheduleWire = { + id: encodePublicId("onc", SCHEDULE_UUID), + object: "oncall_schedule", + name: "Checkout primary", + description: null, + time_zone: "Europe/Berlin", + layers: [ + { + name: "Primary", + participants: ["user_ana", "user_ben"], + rotation: { kind: "weekly", handoff_weekday: "mon", handoff_time: "09:00" }, + starts_at: "2026-11-09T08:00:00.000Z", + restrictions: [{ weekdays: ["mon", "tue"], start_time: "18:00", end_time: "08:00" }], + }, + ], + on_call_now: { + user_id: "user_ana", + starts_at: "2026-11-09T08:00:00.000Z", + ends_at: "2026-11-16T08:00:00.000Z", + }, + chain_ids: [], + created_at: "2026-11-06T00:00:00.000Z", + updated_at: "2026-11-06T00:00:00.000Z", + } + const schedule = Schema.decodeUnknownSync(V2OnCallSchedule)(scheduleWire) + expect(schedule.id).toBe(SCHEDULE_UUID) + expect(Schema.encodeSync(V2OnCallSchedule)(schedule)).toEqual(scheduleWire) + const [layer] = scheduleWire.layers + const invalid = [ + { ...scheduleWire, id: encodePublicId("esc", SCHEDULE_UUID) }, + { ...scheduleWire, layers: [] }, + { ...scheduleWire, layers: [{ ...layer, participants: [] }] }, + { ...scheduleWire, layers: [{ ...layer, rotation: { kind: "daily", handoff_time: "25:00" } }] }, + ] + for (const wire of invalid) expect(() => Schema.decodeUnknownSync(V2OnCallSchedule)(wire)).toThrow() + }) + + it("round-trips ovr_ override ids in overrides and override-sourced shifts", () => { + const SCHEDULE_UUID = "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d" + const OVERRIDE_UUID = "6b5c4d3e-2f10-4b8c-9d8e-7f6a5b4c3d2e" + const overrideWire = { + id: encodePublicId("ovr", OVERRIDE_UUID), + object: "oncall_override", + schedule_id: encodePublicId("onc", SCHEDULE_UUID), + user_id: "user_ben", + starts_at: "2026-11-12T17:00:00.000Z", + ends_at: "2026-11-13T08:00:00.000Z", + created_by: "user_ana", + created_at: "2026-11-10T00:00:00.000Z", + } + const override = Schema.decodeUnknownSync(V2OnCallOverride)(overrideWire) + expect([override.id, override.schedule_id]).toEqual([OVERRIDE_UUID, SCHEDULE_UUID]) + expect(Schema.encodeSync(V2OnCallOverride)(override)).toEqual(overrideWire) + + const shiftWire = { + user_id: "user_ben", + starts_at: overrideWire.starts_at, + ends_at: overrideWire.ends_at, + source: { kind: "override", override_id: overrideWire.id }, + } + const shift = Schema.decodeUnknownSync(V2OnCallShift)(shiftWire) + expect(shift.source).toEqual({ kind: "override", override_id: OVERRIDE_UUID }) + expect(Schema.encodeSync(V2OnCallShift)(shift)).toEqual(shiftWire) + expect(() => + Schema.decodeUnknownSync(V2OnCallOverride)({ ...overrideWire, starts_at: "2026-11-12 17:00" }), + ).toThrow() + }) + it("rejects wrong-prefix rule IDs", () => { expect(() => Schema.decodeUnknownSync(V2AlertRule)({ ...ruleWire, id: encodePublicId("dash", UUID) }), diff --git a/packages/domain/src/mcp-outputs/catalog.ts b/packages/domain/src/mcp-outputs/catalog.ts index 459e8aa927..21893d8fc3 100644 --- a/packages/domain/src/mcp-outputs/catalog.ts +++ b/packages/domain/src/mcp-outputs/catalog.ts @@ -52,6 +52,14 @@ import { EscalationChainMutationOutput, ListEscalationChainsOutput, } from "./escalation-chains" +import { + DeleteOnCallOverrideOutput, + DeleteOnCallScheduleOutput, + ListOnCallSchedulesOutput, + OnCallOverrideMutationOutput, + OnCallScheduleMutationOutput, + WhoIsOnCallOutput, +} from "./oncall-schedules" import { AddDashboardWidgetOutput, CreateDashboardOutput, @@ -147,6 +155,8 @@ export const McpToolOutputs = { compare_periods: ComparePeriodsOutput, create_alert_rule: CreateAlertRuleOutput, create_escalation_chain: EscalationChainMutationOutput, + create_oncall_override: OnCallOverrideMutationOutput, + create_oncall_schedule: OnCallScheduleMutationOutput, create_ingest_rule: IngestRuleMutationOutput, create_scrape_target: ScrapeTargetMutationOutput, create_dashboard: CreateDashboardOutput, @@ -158,6 +168,8 @@ export const McpToolOutputs = { delete_scrape_target: DeleteScrapeTargetOutput, delete_alert_rule: DeleteAlertRuleOutput, delete_escalation_chain: DeleteEscalationChainOutput, + delete_oncall_override: DeleteOnCallOverrideOutput, + delete_oncall_schedule: DeleteOnCallScheduleOutput, acknowledge_alert_incident: AcknowledgeAlertIncidentOutput, delete_slo: DeleteSloOutput, delete_uptime_monitor: DeleteUptimeMonitorOutput, @@ -198,6 +210,7 @@ export const McpToolOutputs = { list_alert_incidents: ListAlertIncidentsOutput, list_alert_rules: ListAlertRulesOutput, list_escalation_chains: ListEscalationChainsOutput, + list_oncall_schedules: ListOnCallSchedulesOutput, list_dashboards: ListDashboardsOutput, list_error_incidents: ListErrorIncidentsOutput, list_error_issue_events: ListErrorIssueEventsOutput, @@ -255,6 +268,7 @@ export const McpToolOutputs = { transition_error_issues: TransitionErrorIssuesOutput, update_alert_rule: UpdateAlertRuleOutput, update_escalation_chain: EscalationChainMutationOutput, + update_oncall_schedule: OnCallScheduleMutationOutput, update_dashboard: UpdateDashboardOutput, update_dashboard_widget: UpdateDashboardWidgetOutput, update_ingest_rule: IngestRuleMutationOutput, @@ -263,6 +277,7 @@ export const McpToolOutputs = { update_uptime_monitor: UptimeMonitorMutationOutput, get_error_notification_policy: GetErrorNotificationPolicyOutput, update_error_notification_policy: UpdateErrorNotificationPolicyOutput, + who_is_on_call: WhoIsOnCallOutput, } as const export type McpToolName = keyof typeof McpToolOutputs diff --git a/packages/domain/src/mcp-outputs/index.ts b/packages/domain/src/mcp-outputs/index.ts index c41b6eb1fe..cce740db61 100644 --- a/packages/domain/src/mcp-outputs/index.ts +++ b/packages/domain/src/mcp-outputs/index.ts @@ -5,6 +5,7 @@ export * from "./services" export * from "./sessions" export * from "./alerts" export * from "./escalation-chains" +export * from "./oncall-schedules" export * from "./ingest-rules" export * from "./dashboards" export * from "./issues" diff --git a/packages/domain/src/mcp-outputs/oncall-schedules.ts b/packages/domain/src/mcp-outputs/oncall-schedules.ts new file mode 100644 index 0000000000..dc95ef3cf6 --- /dev/null +++ b/packages/domain/src/mcp-outputs/oncall-schedules.ts @@ -0,0 +1,88 @@ +/** Output schemas for the on-call schedule MCP tools. */ +import { Schema } from "effect" + +const NullableString = Schema.NullOr(Schema.String) + +export const OnCallPersonRow = Schema.Struct({ + userId: Schema.String, + /** Null when the member directory has no name for the user. */ + name: NullableString, +}) + +export const OnCallLayerRow = Schema.Struct({ + name: Schema.String, + participants: Schema.Array(OnCallPersonRow), + /** e.g. `weekly, Monday 09:00`. */ + rotation: Schema.String, + startsAt: Schema.String, + /** e.g. `mon,tue 18:00-08:00`; empty means always active. */ + restrictions: Schema.Array(Schema.String), +}) + +export const OnCallShiftRow = Schema.Struct({ + user: OnCallPersonRow, + startsAt: Schema.String, + endsAt: Schema.String, + /** `override` or `layer`. */ + source: Schema.String, + /** The layer's name when a layer put the person on call. */ + layerName: NullableString, + overrideId: NullableString, +}) + +export const OnCallScheduleRow = Schema.Struct({ + id: Schema.String, + /** The same schedule's id in the REST API (`onc_…`). */ + publicId: Schema.String, + name: Schema.String, + description: NullableString, + timeZone: Schema.String, + layers: Schema.Array(OnCallLayerRow), + onCallNow: Schema.NullOr( + Schema.Struct({ user: OnCallPersonRow, startsAt: Schema.String, endsAt: Schema.String }), + ), + chainIds: Schema.Array(Schema.String), + createdAt: Schema.String, + updatedAt: Schema.String, +}) + +export const ListOnCallSchedulesOutput = Schema.Struct({ + schedules: Schema.Array(OnCallScheduleRow), + total: Schema.Number, +}) + +export const WhoIsOnCallOutput = Schema.Struct({ + scheduleId: Schema.String, + scheduleName: Schema.String, + timeZone: Schema.String, + at: Schema.String, + /** Null is a gap: nobody is on call. */ + onCall: Schema.NullOr(OnCallShiftRow), + /** The next shift after this one within two weeks, if any. */ + next: Schema.NullOr(OnCallShiftRow), +}) + +export const OnCallScheduleMutationOutput = Schema.Struct({ schedule: OnCallScheduleRow }) + +export const DeleteOnCallScheduleOutput = Schema.Struct({ id: Schema.String, name: Schema.String }) + +export const OnCallOverrideRow = Schema.Struct({ + id: Schema.String, + /** The REST id (`ovr_…`). */ + publicId: Schema.String, + scheduleId: Schema.String, + user: OnCallPersonRow, + startsAt: Schema.String, + endsAt: Schema.String, +}) + +export const OnCallOverrideMutationOutput = Schema.Struct({ + scheduleName: Schema.String, + override: OnCallOverrideRow, +}) + +export const DeleteOnCallOverrideOutput = Schema.Struct({ + id: Schema.String, + scheduleId: Schema.String, + scheduleName: Schema.String, +}) From 2e030b110ab3048a4e52016cd9cdb788536eff61 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 13:38:28 +0100 Subject: [PATCH 19/98] fix(web): a tier routed only by its escalation chain saves from the rule editor --- apps/web/src/page/alerts/editor/derive.ts | 2 +- apps/web/src/page/alerts/editor/view.ts | 3 +- .../web-data/src/alerts/form-utils.test.ts | 33 +++++++++++++++++++ packages/web-data/src/alerts/form-utils.ts | 5 ++- 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/apps/web/src/page/alerts/editor/derive.ts b/apps/web/src/page/alerts/editor/derive.ts index 60f4b78901..0832e80a93 100644 --- a/apps/web/src/page/alerts/editor/derive.ts +++ b/apps/web/src/page/alerts/editor/derive.ts @@ -106,7 +106,7 @@ export const validationIssues = (form: RuleForm): ReadonlyArray => { const intervalIssue = intervalWindowIssue(form) if (intervalIssue !== null) issues.push(intervalIssue) issues.push(...deriveRuleQueryIssues(form), ...deriveTierIssues(form)) - if (form.destinationIds.length === 0) issues.push("Who gets notified") + if (form.destinationIds.length === 0 && form.escalationChainId === null) issues.push("Who gets notified") return issues } diff --git a/apps/web/src/page/alerts/editor/view.ts b/apps/web/src/page/alerts/editor/view.ts index 0d84dc5904..19100c6c88 100644 --- a/apps/web/src/page/alerts/editor/view.ts +++ b/apps/web/src/page/alerts/editor/view.ts @@ -110,7 +110,8 @@ const evaluatedFormOf = stableSlice((form: RuleForm): RuleForm => ({ const surface = (h: H, model: Model, form: RuleForm, timeZone: string): ReadonlyArray => { const evaluated = evaluatedFormOf(form) const isNotifyMissing = - form.destinationIds.length === 0 || deriveTierIssues(form).includes("Critical destinations") + (form.destinationIds.length === 0 && form.escalationChainId === null) || + deriveTierIssues(form).includes("Critical destinations") return [ h.div( [h.Class(cn("mx-auto w-full space-y-10", RULE_FORM_MAX_WIDTH))], diff --git a/packages/web-data/src/alerts/form-utils.test.ts b/packages/web-data/src/alerts/form-utils.test.ts index c3d20698ac..4eab042c41 100644 --- a/packages/web-data/src/alerts/form-utils.test.ts +++ b/packages/web-data/src/alerts/form-utils.test.ts @@ -7,6 +7,7 @@ import { AlertIncidentId, AlertRuleId, ChatWorkspaceId, + EscalationChainId, } from "@maple/domain/http" import { buildDestinationCreateParamsV2, @@ -16,6 +17,8 @@ import { defaultDestinationForm, destinationToFormState, defaultRuleForm, + deriveTierIssues, + formThresholds, ruleFormIsGrouped, deriveRuleQueryIssues, domainThresholdToForm, @@ -638,3 +641,33 @@ describe("microsoft teams destination params", () => { ).toEqual({ type: "microsoft-teams", enabled: true, name: "Ops" }) }) }) + +describe("escalation chains on tiers", () => { + const warnChain = Schema.decodeSync(EscalationChainId)("3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b") + const pageChain = Schema.decodeSync(EscalationChainId)("9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d") + const twoTiers = { + ...defaultRuleForm(), + escalationChainId: warnChain, + escalation: { threshold: "10", recoveryValue: "", destinationIds: [], escalationChainId: pageChain }, + } + + it("accepts a critical tier routed only by its chain", () => { + expect(deriveTierIssues(twoTiers)).not.toContain("Critical destinations") + expect( + deriveTierIssues({ + ...twoTiers, + escalation: { ...twoTiers.escalation, escalationChainId: null }, + }), + ).toContain("Critical destinations") + }) + + it("saves each tier's chain on that tier", () => { + const [warning, critical] = formThresholds(twoTiers) + expect(warning).toMatchObject({ severity: "warning", escalation_chain_id: warnChain }) + expect(critical).toMatchObject({ + severity: "critical", + escalation_chain_id: pageChain, + destination_ids: [], + }) + }) +}) diff --git a/packages/web-data/src/alerts/form-utils.ts b/packages/web-data/src/alerts/form-utils.ts index 97af26bec9..6cbfe78910 100644 --- a/packages/web-data/src/alerts/form-utils.ts +++ b/packages/web-data/src/alerts/form-utils.ts @@ -492,7 +492,10 @@ export function deriveTierIssues(form: RuleFormState): string[] { issues.push("Critical recovery past warning") } recoveryIssue("Critical recovery value", escalation.threshold, escalation.recoveryValue) - if (escalation.destinationIds.length === 0) issues.push("Critical destinations") + // An escalation chain routes the tier on its own, as on the server. + if (escalation.destinationIds.length === 0 && escalation.escalationChainId === null) { + issues.push("Critical destinations") + } } if (Str.trim(form.breachesOutOf).length > 0) { const outOf = Number(form.breachesOutOf) From f8a2a0c6b8e6dcafb6872b8b2cc6d70237bd65cf Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 13:46:20 +0100 Subject: [PATCH 20/98] feat(alerts): escalation chains in the Routing tab, with a dialog to build them Each chain reads as its steps in one line and the rules that start it. The dialog edits name, ordered steps (destinations and a wait), and repeats, with the API's limits checked before save. A refused delete names the rules still holding the chain. --- .../alerts/escalation-dialog/dialog.test.ts | 86 +++++ .../page/alerts/escalation-dialog/dialog.ts | 353 ++++++++++++++++++ .../src/page/alerts/escalation-dialog/view.ts | 325 ++++++++++++++++ apps/web/src/page/alerts/hub/command.ts | 24 +- .../page/alerts/hub/hub-chains.scene.test.ts | 160 ++++++++ apps/web/src/page/alerts/hub/message.ts | 17 +- apps/web/src/page/alerts/hub/model.ts | 18 +- apps/web/src/page/alerts/hub/update.ts | 190 +++++++++- apps/web/src/page/alerts/hub/view-chains.ts | 247 ++++++++++++ apps/web/src/page/alerts/hub/view-routing.ts | 10 +- apps/web/src/page/alerts/hub/view.ts | 11 + .../alerts/shared/escalation-chains.test.ts | 81 ++++ .../page/alerts/shared/escalation-chains.ts | 140 +++++++ .../alerts/shared/escalation-test-fixtures.ts | 113 ++++++ 14 files changed, 1761 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/page/alerts/escalation-dialog/dialog.test.ts create mode 100644 apps/web/src/page/alerts/escalation-dialog/dialog.ts create mode 100644 apps/web/src/page/alerts/escalation-dialog/view.ts create mode 100644 apps/web/src/page/alerts/hub/hub-chains.scene.test.ts create mode 100644 apps/web/src/page/alerts/hub/view-chains.ts create mode 100644 apps/web/src/page/alerts/shared/escalation-chains.test.ts create mode 100644 apps/web/src/page/alerts/shared/escalation-chains.ts create mode 100644 apps/web/src/page/alerts/shared/escalation-test-fixtures.ts diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts new file mode 100644 index 0000000000..d3ff247bb9 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts @@ -0,0 +1,86 @@ +import { Option, Array as Arr, Schema } from "effect" +import { AlertDestinationId } from "@maple/domain/http" +import { describe, expect, it } from "vitest" +import { chainDestinationUuid, chainOf } from "../shared/escalation-test-fixtures" +import { init, Message, type Model, openEdit, payloadOf, update } from "./dialog" + +const asDestination = Schema.decodeSync(AlertDestinationId) +const run = (model: Model, messages: ReadonlyArray) => + Arr.reduce(messages, model, (current, message) => update(current, message).model) +const opened = (): Model => + openEdit( + init("chain"), + chainOf({ + n: 1, + name: "Checkout on-call", + steps: [ + { destinations: [1], wait: 10 }, + { destinations: [2], wait: 15 }, + ], + repeat: 1, + }), + ).model + +describe("escalation chain dialog", () => { + it("an edited chain round-trips to the payload it was read from", () => { + expect(payloadOf(opened())).toEqual( + Option.some({ + name: "Checkout on-call", + description: null, + steps: [ + { + targets: [{ type: "destination", destination_id: chainDestinationUuid(1) }], + wait_minutes: 10, + }, + { + targets: [{ type: "destination", destination_id: chainDestinationUuid(2) }], + wait_minutes: 15, + }, + ], + repeat_count: 1, + }), + ) + }) + + it("holds the API's limits: a name, a destination per step, a 1 to 1440 minute wait, 0 to 5 repeats", () => { + const model = opened() + const key = model.steps[0]?.key ?? "" + const broken = (messages: ReadonlyArray) => Option.isNone(payloadOf(run(model, messages))) + expect(broken([Message.ChangedName({ value: " " })])).toBe(true) + expect(broken([Message.ChangedWait({ key, value: "0" })])).toBe(true) + expect(broken([Message.ChangedWait({ key, value: "1441" })])).toBe(true) + expect(broken([Message.ChangedWait({ key, value: "2.5" })])).toBe(true) + expect(broken([Message.ChangedRepeatCount({ value: "6" })])).toBe(true) + expect( + broken([Message.ClickedRemoveDestination({ key, id: asDestination(chainDestinationUuid(1)) })]), + ).toBe(true) + expect( + broken([Message.ChangedWait({ key, value: "1440" }), Message.ChangedRepeatCount({ value: "5" })]), + ).toBe(false) + }) + + it("a save with errors sends nothing and shows them; a valid one sends once", () => { + const model = run(opened(), [Message.ChangedName({ value: "" })]) + const refused = update(model, Message.ClickedSave()) + expect(refused.commands ?? []).toEqual([]) + expect(refused.model.isShowingErrors).toBe(true) + const saving = update(opened(), Message.ClickedSave()) + expect(Arr.map(saving.commands ?? [], ({ name }) => name)).toEqual(["SaveEscalationChain"]) + expect(update(saving.model, Message.ClickedSave()).commands ?? []).toEqual([]) + }) + + it("steps reorder up and down, add up to ten, and the last one cannot move further", () => { + const model = opened() + const [first, second] = model.steps + const moved = run(model, [Message.ClickedMoveStep({ key: first?.key ?? "", direction: "down" })]) + expect(Arr.map(moved.steps, (step) => step.key)).toEqual([second?.key, first?.key]) + expect( + run(moved, [Message.ClickedMoveStep({ key: first?.key ?? "", direction: "down" })]).steps, + ).toEqual(moved.steps) + const full = run( + model, + Arr.makeBy(12, () => Message.ClickedAddStep()), + ) + expect(full.steps).toHaveLength(10) + }) +}) diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.ts new file mode 100644 index 0000000000..494e719ced --- /dev/null +++ b/apps/web/src/page/alerts/escalation-dialog/dialog.ts @@ -0,0 +1,353 @@ +import { + AlertDestinationId, + EscalationChainDescription, + EscalationChainId, + EscalationChainName, + EscalationRepeatCount, + EscalationWaitMinutes, + MAX_ESCALATION_REPEATS, + MAX_ESCALATION_STEPS, + MAX_ESCALATION_STEP_TARGETS, + MAX_ESCALATION_WAIT_MINUTES, +} from "@maple/domain/http" +import { + V2EscalationChain, + V2EscalationChainCreateParams, + type V2EscalationStep, +} from "@maple/domain/http/v2" +import { 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 * as Menu from "@maple/views/ui/menu" +import type { Resources } from "../../../platform/resources" +import { saveEscalationChain } from "../shared/escalation-chains" + +// "New escalation chain" / "Edit escalation chain": the Routing tab's dialog, as one Submodel. + +/** One step as the form holds it; `key` keeps a step's row (and its menu) across reorders. */ +export const StepDraft = Schema.Struct({ + key: Schema.String, + destinationIds: Schema.Array(AlertDestinationId), + waitMinutes: Schema.String, +}) +export type StepDraft = typeof StepDraft.Type + +export const Model = Schema.Struct({ + dialog: Dialog.Model, + editingId: Schema.NullOr(EscalationChainId), + name: Schema.String, + description: Schema.String, + steps: Schema.Array(StepDraft), + repeatCount: Schema.String, + nextKey: Schema.Number, + /** Each step's "Add destination" menu, by step key. */ + menus: Schema.Record(Schema.String, Menu.Model), + isSaving: Schema.Boolean, + /** A save was tried: every field shows what is wrong with it. */ + isShowingErrors: Schema.Boolean, + saveError: Schema.NullOr(Schema.String), + /** 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 DEFAULT_WAIT_MINUTES = "10" + +const emptyStep = (key: number): StepDraft => ({ + key: `s${key}`, + destinationIds: [], + waitMinutes: DEFAULT_WAIT_MINUTES, +}) + +export const init = (id: string): Model => ({ + dialog: Dialog.init(id, "dialog"), + editingId: null, + name: "", + description: "", + steps: [emptyStep(0)], + repeatCount: "0", + nextKey: 1, + menus: {}, + isSaving: false, + isShowingErrors: false, + saveError: null, + openCount: 0, +}) + +export const Direction = Schema.Literals(["up", "down"]) + +export const Message = defineMessageUnion({ + GotDialogMessage: { message: Dialog.Message }, + ChangedName: { value: Schema.String }, + ChangedDescription: { value: Schema.String }, + ChangedRepeatCount: { value: Schema.String }, + ChangedWait: { key: Schema.String, value: Schema.String }, + ClickedAddStep: {}, + ClickedRemoveStep: { key: Schema.String }, + ClickedMoveStep: { key: Schema.String, direction: Direction }, + GotStepMenuMessage: { key: Schema.String, message: Menu.Message }, + ClickedRemoveDestination: { key: Schema.String, id: AlertDestinationId }, + ClickedSave: {}, + CompletedSave: { openCount: Schema.Number, result: Schema.Result(V2EscalationChain, LoadFailure) }, +}) +export type Message = typeof Message.Type + +export const OutMessage = defineMessageUnion({ + SavedChain: { chain: V2EscalationChain, isNew: Schema.Boolean }, +}) +export type OutMessage = typeof OutMessage.Type + +type Return = Update.ReturnWithOutMessage + +export const menuIdOf = (model: Pick, key: string): string => + `${model.dialog.id}-step-${key}` + +// VALIDATION: the same limits the API's schemas hold. + +const isName = Schema.is(EscalationChainName) +const isDescription = Schema.is(EscalationChainDescription) +const isWait = Schema.is(EscalationWaitMinutes) +const isRepeat = Schema.is(EscalationRepeatCount) + +const numberOf = (raw: string): number | null => (Str.isEmpty(Str.trim(raw)) ? null : Number(Str.trim(raw))) + +export const nameError = (name: string): Option.Option => + Str.isEmpty(Str.trim(name)) + ? Option.some("Name the chain") + : isName(Str.trim(name)) + ? Option.none() + : Option.some("Keep the name to 120 characters") + +export const descriptionError = (description: string): Option.Option => + isDescription(Str.trim(description)) + ? Option.none() + : Option.some("Keep the description to 500 characters") + +export const waitError = (raw: string): Option.Option => + isWait(numberOf(raw)) ? Option.none() : Option.some(`Wait 1 to ${MAX_ESCALATION_WAIT_MINUTES} minutes`) + +export const repeatError = (raw: string): Option.Option => + isRepeat(numberOf(raw)) ? Option.none() : Option.some(`Repeat 0 to ${MAX_ESCALATION_REPEATS} times`) + +export const destinationsError = (ids: ReadonlyArray): Option.Option => + ids.length === 0 + ? Option.some("Pick at least one destination") + : ids.length > MAX_ESCALATION_STEP_TARGETS + ? Option.some(`A step notifies at most ${MAX_ESCALATION_STEP_TARGETS} destinations`) + : Option.none() + +export const stepsError = (steps: ReadonlyArray): Option.Option => + steps.length === 0 + ? Option.some("Add at least one step") + : steps.length > MAX_ESCALATION_STEPS + ? Option.some(`A chain has at most ${MAX_ESCALATION_STEPS} steps`) + : Option.none() + +const stepOf = (step: StepDraft): Option.Option => { + const targets = Arr.map(step.destinationIds, (id) => ({ + type: "destination" as const, + destination_id: id, + })) + const minutes = numberOf(step.waitMinutes) + return Arr.isReadonlyArrayNonEmpty(targets) && + Option.isNone(destinationsError(step.destinationIds)) && + minutes !== null && + isWait(minutes) + ? Option.some({ targets, wait_minutes: minutes }) + : Option.none() +} + +/** The create payload the form describes, or none while any field is wrong. */ +export const payloadOf = (model: Model): Option.Option => { + const steps = Option.all(Arr.map(model.steps, stepOf)) + const repeat = numberOf(model.repeatCount) + if ( + Option.isSome(nameError(model.name)) || + Option.isSome(descriptionError(model.description)) || + Option.isSome(stepsError(model.steps)) || + repeat === null || + !isRepeat(repeat) + ) + return Option.none() + return Option.flatMap(steps, (all) => + Arr.isReadonlyArrayNonEmpty(all) + ? Option.some({ + name: Str.trim(model.name), + description: Str.isEmpty(Str.trim(model.description)) + ? null + : Str.trim(model.description), + steps: all, + repeat_count: repeat, + }) + : Option.none(), + ) +} + +// COMMANDS + +export const SaveEscalationChain = Command.define("SaveEscalationChain", { + args: { + openCount: Schema.Number, + editingId: Schema.NullOr(EscalationChainId), + payload: V2EscalationChainCreateParams, + }, + messages: [Message.CompletedSave], + execute: ({ openCount, editingId, payload }) => + saveEscalationChain(editingId, payload).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, + fields: Pick, +): Return => + liftDialog( + { + ...model, + ...fields, + menus: {}, + isSaving: false, + isShowingErrors: false, + saveError: null, + openCount: model.openCount + 1, + }, + Dialog.open(model.dialog), + ) + +export const openNew = (model: Model): Return => + open(model, { + editingId: null, + name: "", + description: "", + steps: [emptyStep(0)], + repeatCount: "0", + nextKey: 1, + }) + +export const openEdit = (model: Model, chain: V2EscalationChain): Return => + open(model, { + editingId: chain.id, + name: chain.name, + description: chain.description ?? "", + steps: Arr.map(chain.steps, (step, index) => ({ + key: `s${index}`, + destinationIds: Arr.map(step.targets, (target) => target.destination_id), + waitMinutes: String(step.wait_minutes), + })), + repeatCount: String(chain.repeat_count), + nextKey: chain.steps.length, + }) + +const editStep = (model: Model, key: string, edit: (step: StepDraft) => StepDraft): Return => ({ + model: { ...model, steps: Arr.map(model.steps, (step) => (step.key === key ? edit(step) : step)) }, +}) + +const moveStep = (steps: ReadonlyArray, key: string, direction: "up" | "down") => { + const from = Arr.findFirstIndex(steps, (step) => step.key === key) + if (Option.isNone(from)) return steps + const to = direction === "up" ? from.value - 1 : from.value + 1 + const moving = steps[from.value] + const other = steps[to] + if (moving === undefined || other === undefined) return steps + return Arr.map(steps, (step, index) => (index === from.value ? other : index === to ? moving : step)) +} + +const decodeDestinationId = Schema.decodeUnknownOption(AlertDestinationId) + +const stepMenu = (model: Model, key: string, message: Menu.Message): Return => { + const id = menuIdOf(model, key) + const result = Menu.update(model.menus[key] ?? Menu.init(id), message) + const next: Model = { ...model, menus: { ...model.menus, [key]: result.model } } + const commands = Command.mapMessages(result.commands ?? [], (child) => + Message.GotStepMenuMessage({ key, message: child }), + ) + const picked = Option.flatMap( + result.outMessage?._tag === "SelectedItem" ? Option.some(result.outMessage.value) : Option.none(), + decodeDestinationId, + ) + return Option.match(picked, { + onNone: () => ({ model: next, commands }), + onSome: (destinationId) => ({ + ...editStep(next, key, (step) => + step.destinationIds.includes(destinationId) + ? step + : { ...step, destinationIds: [...step.destinationIds, destinationId] }, + ), + commands, + }), + }) +} + +const save = (model: Model): Return => + model.isSaving + ? { model } + : Option.match(payloadOf(model), { + onNone: () => ({ model: { ...model, isShowingErrors: true } }), + onSome: (payload) => ({ + model: { ...model, isSaving: true, isShowingErrors: true, saveError: null }, + commands: [ + SaveEscalationChain({ + openCount: model.openCount, + editingId: model.editingId, + payload, + }), + ], + }), + }) + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotDialogMessage: ({ message: child }) => + // The dialog stays open while a save is in flight. + model.isSaving ? { model } : liftDialog(model, Dialog.update(model.dialog, child)), + ChangedName: ({ value }) => ({ model: { ...model, name: value } }), + ChangedDescription: ({ value }) => ({ model: { ...model, description: value } }), + ChangedRepeatCount: ({ value }) => ({ model: { ...model, repeatCount: value } }), + ChangedWait: ({ key, value }) => editStep(model, key, (step) => ({ ...step, waitMinutes: value })), + ClickedAddStep: () => + model.steps.length >= MAX_ESCALATION_STEPS + ? { model } + : { + model: { + ...model, + steps: [...model.steps, emptyStep(model.nextKey)], + nextKey: model.nextKey + 1, + }, + }, + ClickedRemoveStep: ({ key }) => ({ + model: { ...model, steps: Arr.filter(model.steps, (step) => step.key !== key) }, + }), + ClickedMoveStep: ({ key, direction }) => ({ + model: { ...model, steps: moveStep(model.steps, key, direction) }, + }), + GotStepMenuMessage: ({ key, message: child }) => stepMenu(model, key, child), + ClickedRemoveDestination: ({ key, id }) => + editStep(model, key, (step) => ({ + ...step, + destinationIds: Arr.filter(step.destinationIds, (existing) => existing !== id), + })), + ClickedSave: () => save(model), + CompletedSave: ({ openCount, result }) => + openCount !== model.openCount + ? { model } + : Result.match(result, { + onSuccess: (chain) => ({ + ...liftDialog({ ...model, isSaving: false }, Dialog.close(model.dialog)), + outMessage: OutMessage.SavedChain({ chain, isNew: model.editingId === null }), + }), + onFailure: (failure) => ({ + model: { ...model, isSaving: false, saveError: failure.message }, + }), + }), + }) diff --git a/apps/web/src/page/alerts/escalation-dialog/view.ts b/apps/web/src/page/alerts/escalation-dialog/view.ts new file mode 100644 index 0000000000..d1fe8f2b6e --- /dev/null +++ b/apps/web/src/page/alerts/escalation-dialog/view.ts @@ -0,0 +1,325 @@ +import { MAX_ESCALATION_STEPS } from "@maple/domain/http" +import type { AlertDestinationRow } from "@maple/web-data/collections/alerts" +import { Option, Array as Arr } from "effect" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" +import { button } from "@maple/views/ui/button" +import { + fieldDescription, + fieldError, + fieldErrorText, + type FieldIds, + fieldLabel, + input, +} from "@maple/views/ui/field" +import { formDialog } from "@maple/views/ui/form-dialog" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" +import type { FieldState } from "@maple/views/ui/input" +import * as Menu from "@maple/views/ui/menu" +import { panel } from "@maple/views/ui/panel" +import { hstack, vstack } from "@maple/views/ui/stack" +import { textarea } from "@maple/views/ui/textarea" +import { typography } from "@maple/views/ui/typography" +import { destinationPicker } from "../../kit/destination-picker" +import { formatWait } from "../shared/escalation-chains" +import { + descriptionError, + destinationsError, + menuIdOf, + Message, + type Model, + nameError, + repeatError, + type StepDraft, + stepsError, + waitError, +} from "./dialog" + +type H = HtmlBuilder + +export type ViewInputs = Readonly<{ + id: string + /** The synced destinations steps pick from; `null` until they arrive. */ + destinations: ReadonlyArray | null +}> + +const stateOf = (value: string, isInvalid: boolean): FieldState => ({ + isInvalid, + isDirty: false, + isFilled: value !== "", + isFocused: false, + isTouched: false, +}) + +const idsOf = (id: string, name: string, hasDescription = false): FieldIds => ({ + control: `${id}-${name}`, + label: `${id}-${name}-label`, + error: `${id}-${name}-error`, + ...(hasDescription ? { description: `${id}-${name}-description` } : undefined), +}) + +/** A field's error once a save was tried, or at once for a number typed out of range. */ +const shownError = (model: Model, error: Option.Option, isImmediate = false): Option.Option => + model.isShowingErrors || isImmediate ? error : Option.none() + +const errorLine = ( + h: H, + ids: FieldIds, + state: FieldState, + error: Option.Option, +): ReadonlyArray => + Option.match(error, { + onNone: () => [], + onSome: (text) => [fieldError(h, { ids, state, attributes: [h.Role("alert")] }, [text])], + }) + +const textField = ( + h: H, + options: Readonly<{ + ids: FieldIds + label: string + value: string + placeholder: string + error: Option.Option + onInput: (value: string) => Message + }>, +): Html => { + const state = stateOf(options.value, Option.isSome(options.error)) + return vstack(h, { gap: "1.5" }, [ + fieldLabel(h, { ids: options.ids, state }, options.label), + input(h, { + ids: options.ids, + state, + attributes: [ + h.Value(options.value), + h.Placeholder(options.placeholder), + h.OnInput(options.onInput), + h.Attribute("autocomplete", "off"), + ], + }), + ...errorLine(h, options.ids, state, options.error), + ]) +} + +/** A short number input on one line with its label. */ +const numberField = ( + h: H, + options: Readonly<{ + ids: FieldIds + label: string + value: string + min: number + max: number + description?: string + error: Option.Option + onInput: (value: string) => Message + }>, +): Html => { + const state = stateOf(options.value, Option.isSome(options.error)) + return vstack(h, { gap: "1.5" }, [ + hstack(h, { gap: "3", wrap: true }, [ + fieldLabel(h, { ids: options.ids, state, className: "font-normal" }, options.label), + input(h, { + ids: options.ids, + state, + className: "w-20", + attributes: [ + h.Type("number"), + h.Min(String(options.min)), + h.Attribute("max", String(options.max)), + h.Attribute("step", "1"), + h.Value(options.value), + h.OnInput(options.onInput), + ], + }), + ]), + ...(options.description === undefined + ? [] + : [fieldDescription(h, { ids: options.ids, state }, [options.description])]), + ...errorLine(h, options.ids, state, options.error), + ]) +} + +const isOutOfRange = (raw: string, error: Option.Option) => raw !== "" && Option.isSome(error) + +const waitLabel = (model: Model, index: number): string => + index < model.steps.length - 1 + ? "Then wait, in minutes" + : model.repeatCount !== "" && model.repeatCount !== "0" + ? "Wait before repeating, in minutes" + : "Wait before the chain ends, in minutes" + +const stepBlock = (h: H, model: Model, inputs: ViewInputs, step: StepDraft, index: number): Html => { + const count = model.steps.length + const previous = index === 0 ? undefined : model.steps[index - 1] + const wait = waitError(step.waitMinutes) + const destinations = shownError(model, destinationsError(step.destinationIds)) + const key = step.key + return panel(h, { padded: "sm", className: "gap-3", attributes: [h.Key(key)] }, [ + hstack(h, { gap: "2", justify: "between" }, [ + hstack(h, { gap: "2", shrink: true }, [ + typography(h, { size: "sm", weight: "medium", className: "shrink-0 whitespace-nowrap" }, [ + `Step ${index + 1}`, + ]), + typography(h, { size: "xs", tone: "muted", truncate: true }, [ + previous === undefined + ? "pages at once" + : Option.isNone(waitError(previous.waitMinutes)) + ? `after ${formatWait(Number(previous.waitMinutes))} without an ack` + : "after the wait above", + ]), + ]), + hstack(h, { gap: "0.5" }, [ + iconButton( + h, + { + label: `Move step ${index + 1} up`, + isDisabled: index === 0, + attributes: [h.OnClick(Message.ClickedMoveStep({ key, direction: "up" }))], + }, + [icon(h, "ArrowUpIcon")], + ), + iconButton( + h, + { + label: `Move step ${index + 1} down`, + isDisabled: index === count - 1, + attributes: [h.OnClick(Message.ClickedMoveStep({ key, direction: "down" }))], + }, + [icon(h, "ArrowDownIcon")], + ), + iconButton( + h, + { + label: `Remove step ${index + 1}`, + isDisabled: count === 1, + attributes: [h.OnClick(Message.ClickedRemoveStep({ key }))], + }, + [icon(h, "TrashIcon")], + ), + ]), + ]), + destinationPicker(h, { + rows: inputs.destinations, + selectedIds: step.destinationIds, + menu: model.menus[key] ?? Menu.init(menuIdOf(model, key)), + toMenuMessage: (message) => Message.GotStepMenuMessage({ key, message }), + onRemove: (id) => Message.ClickedRemoveDestination({ key, id }), + emptyText: + inputs.destinations !== null && inputs.destinations.length === 0 + ? "No destinations exist yet. Add one in Routing first." + : "Pick who this step pages", + }), + ...Option.match(destinations, { + onNone: () => [], + onSome: (text) => [fieldErrorText(h, { attributes: [h.Role("alert")] }, [text])], + }), + numberField(h, { + ids: idsOf(model.dialog.id, `wait-${key}`), + label: waitLabel(model, index), + value: step.waitMinutes, + min: 1, + max: 1440, + error: shownError(model, wait, isOutOfRange(step.waitMinutes, wait)), + onInput: (value) => Message.ChangedWait({ key, value }), + }), + ]) +} + +const stepsSection = (h: H, model: Model, inputs: ViewInputs): Html => + vstack(h, { gap: "3" }, [ + hstack(h, { gap: "2", justify: "between" }, [ + typography(h, { size: "sm", weight: "medium" }, ["Steps"]), + typography(h, { size: "xs", tone: "muted", numeric: true }, [ + `${model.steps.length} of ${MAX_ESCALATION_STEPS}`, + ]), + ]), + ...Arr.map(model.steps, (step, index) => stepBlock(h, model, inputs, step, index)), + ...Option.match(shownError(model, stepsError(model.steps)), { + onNone: () => [], + onSome: (text) => [fieldErrorText(h, { attributes: [h.Role("alert")] }, [text])], + }), + button( + h, + { + variant: "outline", + size: "sm", + className: "self-start", + isDisabled: model.steps.length >= MAX_ESCALATION_STEPS, + attributes: [h.OnClick(Message.ClickedAddStep())], + }, + [icon(h, "PlusIcon"), "Add step"], + ), + ]) + +const descriptionField = (h: H, model: Model): Html => { + const ids = idsOf(model.dialog.id, "description") + const error = shownError(model, descriptionError(model.description)) + const state = stateOf(model.description, Option.isSome(error)) + return vstack(h, { gap: "1.5" }, [ + fieldLabel(h, { ids, state }, "Description"), + textarea(h, { + id: ids.control, + defaultValue: model.description, + attributes: [ + h.AriaLabelledBy(ids.label), + h.Placeholder("Optional: who is on this chain and when to change it"), + h.Rows(2), + h.OnInput((value) => Message.ChangedDescription({ value })), + ], + }), + ...errorLine(h, ids, state, error), + ]) +} + +/** The escalation chain dialog: name, ordered steps of destinations and waits, and repeats. */ +export const view = Submodel.defineView((model, inputs, h) => { + const repeat = repeatError(model.repeatCount) + const isEditing = model.editingId !== null + return formDialog(h, { + slotId: `${inputs.id}-dialog`, + model: model.dialog, + toParentMessage: (message) => Message.GotDialogMessage({ message }), + className: "sm:max-w-xl", + title: isEditing ? "Edit escalation chain" : "New escalation chain", + description: + "Each step pages its destinations, then waits for someone to acknowledge before paging the next. Acknowledging pauses the chain and resolving ends it.", + panelClassName: "space-y-5", + fields: [ + textField(h, { + ids: idsOf(model.dialog.id, "name"), + label: "Name", + value: model.name, + placeholder: "Checkout on-call", + error: shownError(model, nameError(model.name)), + onInput: (value) => Message.ChangedName({ value }), + }), + descriptionField(h, model), + stepsSection(h, model, inputs), + numberField(h, { + ids: idsOf(model.dialog.id, "repeat", true), + label: "Repeat the whole chain", + value: model.repeatCount, + min: 0, + max: 5, + description: "Extra passes after the first, 0 to 5. 0 runs it once.", + error: shownError(model, repeat, isOutOfRange(model.repeatCount, repeat)), + onInput: (value) => Message.ChangedRepeatCount({ value }), + }), + ...(model.saveError === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, [isEditing ? "Couldn't save chain" : "Couldn't create chain"]), + alertDescription(h, [model.saveError]), + ]), + ]), + ], + onSubmit: Message.ClickedSave(), + submitLabel: isEditing ? "Save chain" : "Create chain", + isPending: model.isSaving, + }) +}) diff --git a/apps/web/src/page/alerts/hub/command.ts b/apps/web/src/page/alerts/hub/command.ts index 060548be35..1f470ee9f6 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, EscalationChainId } 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" @@ -17,6 +17,7 @@ import { testDestination, updateDestination, } from "../shared/commands" +import { deleteEscalationChain, listEscalationChains } from "../shared/escalation-chains" import { listSloRows } from "../shared/slo-commands" import { deleteMonitor, listMonitors, setMonitorEnabled } from "../shared/uptime-commands" import { Message } from "./message" @@ -164,3 +165,24 @@ export const FetchHubSlos = Command.define("FetchAlertsHubSlos", { Effect.map((result) => Message.CompletedFetchSlos({ key, result })), ), }) + +/** The Routing tab's escalation chains. */ +export const FetchHubChains = Command.define("FetchAlertsHubChains", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchChains], + execute: ({ key }) => + listEscalationChains.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchChains({ key, result })), + ), +}) + +export const DeleteChain = Command.define("DeleteEscalationChain", { + args: { chainId: EscalationChainId }, + messages: [Message.CompletedDeleteChain], + execute: ({ chainId }) => + deleteEscalationChain(chainId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedDeleteChain({ chainId, result })), + ), +}) diff --git a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts new file mode 100644 index 0000000000..08609164f5 --- /dev/null +++ b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts @@ -0,0 +1,160 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { Result, Schema, Array as Arr } from "effect" +import * as Animation from "@foldkit/ui/animation" +import { EscalationChainId } from "@maple/domain/http" +import * as Dialog from "@maple/views/ui/dialog" +import * as Menu from "@maple/views/ui/menu" +import { MountDialog } from "@maple/views/ui/dialog" +import { + click, + Command, + expect as expectView, + Mount, + given, + role, + scene, + selector, + submit, + text, + withViewInputs, +} from "foldkit/scene" +import { describe, expect, test } from "vitest" +import type { V2EscalationChain } from "@maple/domain/http/v2" +import type { PageContext } from "../../contract" +import { CHAIN_IN_USE_CODE } from "../shared/escalation-chains" +import { chainDestinations, chainUuid, populatedChains } from "../shared/escalation-test-fixtures" +import { NOW, ORG, ruleId, ruleRow } from "../shared/test-rows" +import { Message } from "./message" +import type { Model } from "./model" +import { chainMenuId, 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 names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) + +const ENGINEER = Policy.grantsOf(["org:engineer"]) +const MEMBER = Policy.grantsOf(["org:member"]) +const RULES = [ruleRow(1, { name: "Checkout errors" }), ruleRow(2, { name: "Checkout p95" }), ruleRow(3)] +const CHAINS = populatedChains([ruleId(1), ruleId(2), ruleId(3)]) + +const onRouting = (grants: ReturnType, chains: ReadonlyArray) => { + const entered = init({ _tag: "Alerts", search: { tab: "routing" } }, context) + expect(names(entered.commands)).toContain("FetchAlertsHubChains") + return Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.CompletedFetchSession({ orgId: ORG, grants }), + Message.UpdatedDestinations({ orgId: ORG, rows: chainDestinations }), + Message.UpdatedRules({ orgId: ORG, rows: RULES }), + Message.CompletedFetchChains({ + key: entered.model.chains.key ?? "", + result: Result.succeed(chains), + }), + ], + entered.model, + (model, message) => pageUpdate(model, message).model, + ) +} + +const openDialog = () => [ + Command.resolve(Animation.WaitForPaint, Animation.Message.CompletedWaitForPaint({ generation: 1 })), + Command.resolve(Animation.WaitForAnimationSettled, Animation.Message.EndedAnimation({ generation: 1 })), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), +] + +describe("alerts hub, escalation chains", () => { + test("an org without chains learns what one is and can create the first", () => { + scene( + { update: pageUpdate, view }, + given(onRouting(ENGINEER, [])), + expectView(text("No escalation chains")).toExist(), + expectView(text(/waits for someone to acknowledge, then pages the next/u)).toExist(), + expectView(role("button", { name: "Create chain" })).toExist(), + ) + }) + + test("each chain reads as its steps in one line and the rules that start it", () => { + scene( + { update: pageUpdate, view }, + given(onRouting(ENGINEER, CHAINS)), + expectView(text("Checkout on-call")).toExist(), + expectView( + text( + "Slack #alerts-api, then PagerDuty Checkout after 10m, then Email on-call after 15m; repeats twice", + ), + ).toExist(), + expectView(text("Used by 2 rules")).toExist(), + expectView(text("Not used by any rule")).toExist(), + expectView(role("button", { name: "Actions for Checkout on-call" })).toExist(), + expectView(role("button", { name: "New chain" })).toExist(), + ) + }) + + test("a member sees the chains but cannot create, edit or delete one", () => { + scene( + { update: pageUpdate, view }, + given(onRouting(MEMBER, [])), + expectView(role("button", { name: "Create chain" })).not.toExist(), + expectView(text(/Ask an org admin to create one/u)).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(onRouting(MEMBER, CHAINS)), + expectView(role("button", { name: "Actions for Checkout on-call" })).not.toExist(), + ) + }) + + test("saving a new chain without a name or destinations shows what is missing and sends nothing", () => { + scene( + { update: pageUpdate, view }, + given(onRouting(ENGINEER, CHAINS)), + click(role("button", { name: "New chain" })), + ...openDialog(), + expectView(role("dialog", { name: "New escalation chain" })).toExist(), + submit(selector("form")), + Command.expectNone(), + expectView(text("Name the chain")).toExist(), + expectView(text("Pick at least one destination")).toExist(), + ) + }) + + test("a refused delete stays open and names the rules that still start the chain", () => { + const model = onRouting(ENGINEER, CHAINS) + const chainId = Schema.decodeSync(EscalationChainId)(chainUuid(1)) + const asked = pageUpdate( + pageUpdate( + model, + Message.GotRowMenuMessage({ + menuId: chainMenuId(chainId), + message: Menu.Message.ClickedItem({ index: 2, item: { kind: "Item", value: "delete" } }), + }), + ).model, + Message.ClickedConfirmDeleteChain(), + ) + expect(names(asked.commands)).toEqual(["DeleteEscalationChain"]) + const refused = pageUpdate( + asked.model, + Message.CompletedDeleteChain({ + chainId, + result: Result.fail({ + _tag: "@maple/http/errors/EscalationChainInUseError", + type: "conflict_error", + code: CHAIN_IN_USE_CODE, + title: "Escalation chain is in use", + message: "The escalation chain is used by one or more alert rules.", + retryable: false, + recovery: "fix_request", + }), + }), + ) + expect(refused.model.deleteChainError).toBe( + "Still used by Checkout errors, Checkout p95. Remove it from their severity tiers first.", + ) + expect(refused.model.confirmDeleteChainId).toBe(chainId) + }) +}) diff --git a/apps/web/src/page/alerts/hub/message.ts b/apps/web/src/page/alerts/hub/message.ts index d33bbad627..b11118d95c 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, + EscalationChainId, +} from "@maple/domain/http" +import { V2EscalationChain, 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 ChainDialog from "../escalation-dialog/dialog" import { SloListRow, TestOutcome, UptimeKindFilter, UptimeStatusFilter } from "./model" import { ServiceNamespaces } from "./uptime-groups" @@ -71,5 +77,12 @@ export const Message = defineMessageUnion({ GotDeleteMonitorDialogMessage: { message: Dialog.Message }, CompletedFetchSlos: { key: Schema.String, result: outcome(Schema.Array(SloListRow)) }, ClickedRetrySlos: {}, + CompletedFetchChains: { key: Schema.String, result: outcome(Schema.Array(V2EscalationChain)) }, + ClickedRetryChains: {}, + ClickedNewChain: {}, + GotChainDialogMessage: { message: ChainDialog.Message }, + ClickedConfirmDeleteChain: {}, + CompletedDeleteChain: { chainId: EscalationChainId, result: outcome(Schema.Void) }, + GotDeleteChainDialogMessage: { message: Dialog.Message }, }) 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 2eedc2959c..71bade60a7 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 { V2Slo, V2SloStatus, V2UptimeMonitor } from "@maple/domain/http/v2" +import { + AlertDeliveryEventDocument, + AlertDestinationId, + AlertRuleId, + EscalationChainId, +} from "@maple/domain/http" +import { V2EscalationChain, V2Slo, V2SloStatus, 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 ChainDialog from "../escalation-dialog/dialog" import { ServiceNamespaces } from "./uptime-groups" // The `/alerts` hub. @@ -80,6 +86,14 @@ export const Model = Schema.Struct({ deleteMonitorDialog: Dialog.Model, /** SLOs tab: `slos.list` and each SLO's status, read only while the tab is open. */ slos: KeyedSchema(Schema.Array(SloListRow), LoadFailure), + /** Routing tab: `escalationChains.list`, read only while the tab is open. */ + chains: KeyedSchema(Schema.Array(V2EscalationChain), LoadFailure), + chainDialog: ChainDialog.Model, + /** The chain awaiting delete confirmation, the delete in flight, and why the last one was refused. */ + confirmDeleteChainId: Schema.NullOr(EscalationChainId), + deletingChainId: Schema.NullOr(EscalationChainId), + deleteChainError: Schema.NullOr(Schema.String), + deleteChainDialog: Dialog.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 466ac5520d..62e61d242e 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -1,5 +1,6 @@ import type { UptimeMonitorId } from "@maple/domain" -import type { AlertDestinationId, AlertRuleId } from "@maple/domain/http" +import type { AlertDestinationId, AlertRuleId, EscalationChainId } from "@maple/domain/http" +import type { V2EscalationChain } from "@maple/domain/http/v2" 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,12 +15,16 @@ 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 ChainDialog from "../escalation-dialog/dialog" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" import { alertsHref, createHref, ruleHref } from "../shared/links" +import { CHAIN_IN_USE_CODE, CHAIN_LIST_LIMIT } from "../shared/escalation-chains" import { newSloHref, SLO_LIST_LIMIT } from "../shared/slo-commands" import { editMonitorHref, newMonitorHref, UPTIME_LIST_LIMIT } from "../shared/uptime-commands" import { + DeleteChain, DeleteDestination, + FetchHubChains, DeleteMonitor, FetchHubMonitors, FetchHubSlos, @@ -44,6 +49,8 @@ type HubRoute = Extract const DELETE_DIALOG_ID = "alerts-delete-destination" const DELETE_MONITOR_DIALOG_ID = "alerts-delete-monitor" +const DELETE_CHAIN_DIALOG_ID = "alerts-delete-chain" +export const CHAIN_DIALOG_ID = "alerts-escalation-chain" export const NEW_RULE_MENU_ID = "alerts-new-rule-menu" /** Runs the delete dialog's update; `Closed` (Escape, Cancel, success) forgets the target. */ @@ -128,6 +135,21 @@ const requestSlos = (model: Model, context: PageContext, mode: Keyed.ReadMode): }) } +const chainsKey = (context: PageContext) => + requestKey(context.orgId, "escalationChains.list", { query: { limit: CHAIN_LIST_LIMIT } }) + +/** The Routing tab reads the escalation chains only while it is open. */ +const requestChains = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => { + if (resolveTab(model.search.tab) !== "routing") return { model, commands: [] } + return Option.match(Keyed.requestFor(model.chains, chainsKey(context), mode), { + onNone: () => ({ model, commands: [] }), + onSome: (chains) => ({ + model: { ...model, chains }, + commands: [FetchHubChains({ key: chains.key })], + }), + }) +} + /** `reactivityKeys: ["alertDeliveryEvents"]`: both reads revalidate. */ const refreshDeliveries = (model: Model, context: PageContext) => { const overview = requestDeliveries(model, context, "Refresh") @@ -169,13 +191,20 @@ export const init = (route: HubRoute, context: PageContext): Return => { confirmDeleteMonitorId: null, deleteMonitorDialog: Dialog.init(DELETE_MONITOR_DIALOG_ID, "alert-dialog"), slos: Keyed.idle(), + chains: Keyed.idle(), + chainDialog: ChainDialog.init(CHAIN_DIALOG_ID), + confirmDeleteChainId: null, + deletingChainId: null, + deleteChainError: null, + deleteChainDialog: Dialog.init(DELETE_CHAIN_DIALOG_ID, "alert-dialog"), } const deliveries = requestDeliveries(model, context, "Load") const routing = requestRoutingDeliveries(deliveries.model, context, "Load") const monitors = requestMonitors(routing.model, context, "Load") const slos = requestSlos(monitors.model, context, "Load") + const chains = requestChains(slos.model, context, "Load") return { - model: slos.model, + model: chains.model, commands: [ ReadHubClock(), FetchHubSession({ orgId: context.orgId }), @@ -183,6 +212,7 @@ export const init = (route: HubRoute, context: PageContext): Return => { ...routing.commands, ...monitors.commands, ...slos.commands, + ...chains.commands, ], } } @@ -191,9 +221,10 @@ export const routeChanged = (model: Model, route: HubRoute, context: PageContext const routing = requestRoutingDeliveries({ ...model, search: route.search }, context, "Load") const monitors = requestMonitors(routing.model, context, "Load") const slos = requestSlos(monitors.model, context, "Load") + const chains = requestChains(slos.model, context, "Load") return { - model: slos.model, - commands: [...routing.commands, ...monitors.commands, ...slos.commands], + model: chains.model, + commands: [...routing.commands, ...monitors.commands, ...slos.commands, ...chains.commands], } } @@ -273,6 +304,134 @@ const liftDestinationDialog = (model: Model, step: ReturnType => + Option.getOrElse(AsyncData.getData(model.chains.data), () => []) + +/** A write changed the chains: read them again under a new generation. */ +const refreshChains = (model: Model, context: PageContext): Return => { + const step = requestChains(model, context, "Supersede") + return { model: step.model, commands: step.commands } +} + +/** The chain dialog's outcome: a toast, and the list read again. */ +const liftChainDialog = ( + model: Model, + context: PageContext, + step: ReturnType, +): Return => { + const lifted: Model = { ...model, chainDialog: step.model } + const commands = FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotChainDialogMessage({ message }), + ) + if (step.outMessage === undefined) return { model: lifted, commands } + const refreshed = refreshChains(lifted, context) + return { + model: refreshed.model, + commands: [...commands, ...(refreshed.commands ?? [])], + outMessage: toast( + "success", + step.outMessage.isNew ? "Escalation chain created" : "Escalation chain saved", + ), + } +} + +/** The names of the rules a chain says use it, from the synced rules. */ +export const chainRuleNames = ( + model: Pick, + chain: V2EscalationChain, +): ReadonlyArray => + Array.map(chain.rule_ids, (id) => + Option.getOrElse( + Option.map( + Array.findFirst(model.rules ?? [], (row) => row.id === id), + (row) => row.name, + ), + () => "a rule you cannot see", + ), + ) + +/** Why a delete was refused: the API's 409 names no rules, so they come from the chain's own list. */ +const inUseMessage = (model: Model, chainId: EscalationChainId): string => + Option.match( + Array.findFirst(chainsOf(model), (chain) => chain.id === chainId), + { + onNone: () => "Alert rules still start this chain. Remove it from their severity tiers first.", + onSome: (chain) => + `Still used by ${Array.join(chainRuleNames(model, chain), ", ")}. Remove it from their severity tiers first.`, + }, + ) + +/** Runs the chain delete dialog's update; `Closed` forgets the target and its error. */ +const updateDeleteChainDialog = (model: Model, step: ReturnType): Return => { + const isClosed = step.outMessage?._tag === "Closed" + return { + model: { + ...model, + deleteChainDialog: step.model, + confirmDeleteChainId: isClosed ? null : model.confirmDeleteChainId, + deleteChainError: isClosed ? null : model.deleteChainError, + }, + commands: FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotDeleteChainDialogMessage({ message }), + ), + } +} + +const pickedChainAction = (model: Model, context: PageContext, rawId: string, value: string): Return => + Option.match( + Array.findFirst(chainsOf(model), (chain) => chain.id === rawId), + { + onNone: () => ({ model }), + onSome: (chain): Return => + value === "delete" + ? updateDeleteChainDialog( + { ...model, confirmDeleteChainId: chain.id, deleteChainError: null }, + Dialog.open(model.deleteChainDialog), + ) + : liftChainDialog(model, context, ChainDialog.openEdit(model.chainDialog, chain)), + }, + ) + +const confirmDeleteChain = (model: Model): Return => { + const id = model.confirmDeleteChainId + return id === null || model.deletingChainId !== null + ? { model } + : { + model: { ...model, deletingChainId: id, deleteChainError: null }, + commands: [DeleteChain({ chainId: id })], + } +} + +const deletedChain = ( + model: Model, + context: PageContext, + chainId: EscalationChainId, + result: Result.Result, +): Return => { + const settled = { ...model, deletingChainId: null } + return Result.match(result, { + onSuccess: (): Return => { + const closed = updateDeleteChainDialog(settled, Dialog.close(settled.deleteChainDialog)) + const refreshed = refreshChains(closed.model, context) + return { + model: refreshed.model, + commands: [...(closed.commands ?? []), ...(refreshed.commands ?? [])], + outMessage: toast("success", "Escalation chain deleted"), + } + }, + // The dialog stays open on a refusal, saying which rules hold the chain. + onFailure: (failure): Return => ({ + model: { + ...settled, + deleteChainError: + failure.code === CHAIN_IN_USE_CODE ? inUseMessage(settled, chainId) : failure.message, + }, + }), + }) +} + // ROW MENUS /** @@ -282,6 +441,7 @@ const liftDestinationDialog = (model: Model, step: ReturnType `rule:${ruleId}@${groupKey}` export const destinationMenuId = (destinationId: AlertDestinationId) => `destination:${destinationId}` export const monitorMenuId = (monitorId: UptimeMonitorId) => `monitor:${monitorId}` +export const chainMenuId = (chainId: EscalationChainId) => `chain:${chainId}` const monitorsOf = (model: Model) => Option.getOrElse(AsyncData.getData(model.monitors.data), () => []) @@ -367,10 +527,11 @@ const updateNewRuleMenu = (model: Model, message: Menu.Message): Return => { } } -const pickedFromMenu = (model: Model, menuId: string, value: string): Return => { +const pickedFromMenu = (model: Model, context: PageContext, menuId: string, value: string): Return => { const [kind, ...rest] = Str.split(":")(menuId) const target = Array.join(rest, ":") if (kind === "monitor") return pickedMonitorAction(model, target, value) + if (kind === "chain") return pickedChainAction(model, context, target, value) if (kind === "rule") { const id = Str.split("@")(target)[0] const rule = Option.map( @@ -409,7 +570,7 @@ const pickedFromMenu = (model: Model, menuId: string, value: string): Return => ) } -const updateMenu = (model: Model, menuId: string, message: Menu.Message): Return => { +const updateMenu = (model: Model, context: PageContext, menuId: string, message: Menu.Message): Return => { const result = Menu.update(model.menus[menuId] ?? Menu.init(menuId), message) const next: Model = { ...model, menus: { ...model.menus, [menuId]: result.model } } const commands = FoldkitCommand.mapMessages(result.commands ?? [], (child) => @@ -421,7 +582,7 @@ const updateMenu = (model: Model, menuId: string, message: Menu.Message): Return return Option.match(picked, { onNone: () => ({ model: next, commands }), onSome: (value) => { - const after = pickedFromMenu(next, menuId, value) + const after = pickedFromMenu(next, context, menuId, value) return { ...after, commands: [...commands, ...(after.commands ?? [])] } }, }) @@ -530,7 +691,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re liftDestinationDialog(model, DestinationDialog.openNew(model.destinationDialog)), GotDestinationDialogMessage: ({ message: child }) => liftDestinationDialog(model, DestinationDialog.update(model.destinationDialog, child)), - GotRowMenuMessage: ({ menuId, message: child }) => updateMenu(model, menuId, child), + GotRowMenuMessage: ({ menuId, message: child }) => updateMenu(model, context, menuId, child), GotNewRuleMenuMessage: ({ message: child }) => updateNewRuleMenu(model, child), CompletedFetchMonitors: ({ key, result }) => ({ model: { ...model, monitors: Keyed.accept(model.monitors, key, result) }, @@ -548,6 +709,19 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedFetchSlos: ({ key, result }) => ({ model: { ...model, slos: Keyed.accept(model.slos, key, result) }, }), + CompletedFetchChains: ({ key, result }) => ({ + model: { ...model, chains: Keyed.accept(model.chains, key, result) }, + }), + ClickedRetryChains: () => refreshChains(model, context), + ClickedNewChain: () => liftChainDialog(model, context, ChainDialog.openNew(model.chainDialog)), + GotChainDialogMessage: ({ message: child }) => + liftChainDialog(model, context, ChainDialog.update(model.chainDialog, child)), + ClickedConfirmDeleteChain: () => confirmDeleteChain(model), + CompletedDeleteChain: ({ chainId, result }) => deletedChain(model, context, chainId, result), + GotDeleteChainDialogMessage: ({ message: child }) => + model.deletingChainId !== null + ? { model } + : updateDeleteChainDialog(model, Dialog.update(model.deleteChainDialog, child)), ClickedRetrySlos: () => { const step = requestSlos(model, context, "Supersede") return { model: step.model, commands: step.commands } diff --git a/apps/web/src/page/alerts/hub/view-chains.ts b/apps/web/src/page/alerts/hub/view-chains.ts new file mode 100644 index 0000000000..4902abbcd4 --- /dev/null +++ b/apps/web/src/page/alerts/hub/view-chains.ts @@ -0,0 +1,247 @@ +import type { AlertDestinationId } from "@maple/domain/http" +import type { V2EscalationChain } from "@maple/domain/http/v2" +import { pluralize } from "@maple/ui/lib/format" +import type { AlertDestinationRow } from "@maple/web-data/collections/alerts" +import { Array as Arr, Option } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { borderedBox } from "@maple/views/ui/bordered-box" +import { button } from "@maple/views/ui/button" +import { confirmDialog } from "@maple/views/ui/confirm-dialog" +import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "@maple/views/ui/empty" +import { errorState } from "@maple/views/ui/error-state" +import { icon } from "@maple/views/ui/icon" +import * as Menu from "@maple/views/ui/menu" +import type { MenuEntry } from "@maple/views/ui/menu-view" +import { rowActionsMenu } from "@maple/views/ui/row-actions-menu" +import { emptyActions } from "@maple/views/ui/signal-empty-state" +import { skeleton } from "@maple/views/ui/skeleton" +import { hstack, vstack } from "@maple/views/ui/stack" +import { tooltipTrigger } from "@maple/views/ui/tooltip-host" +import { typography } from "@maple/views/ui/typography" +import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" +import { sectionHeading } from "../../kit/section-heading" +import { chainSummary } from "../shared/escalation-chains" +import { Message } from "./message" +import { canManage, type Model } from "./model" +import { chainMenuId, chainRuleNames } from "./update" + +// The Routing tab's "Escalation chains" section and its delete confirmation. + +type H = HtmlBuilder + +const nameOfDestination = + (rows: ReadonlyArray) => + (id: AlertDestinationId): string => + Option.getOrElse( + Option.map( + Arr.findFirst(rows, (row) => row.id === id), + (row) => row.name, + ), + () => "an unavailable destination", + ) + +/** `ChainRow`: name, the steps in one line, and how many rules start it. */ +export const chainRow = ( + h: H, + model: Model, + chain: V2EscalationChain, + destinations: ReadonlyArray, +): Html => { + const summary = chainSummary(chain, nameOfDestination(destinations)) + const ruleNames = chainRuleNames(model, chain) + const menuId = chainMenuId(chain.id) + const entries: ReadonlyArray = [ + { _tag: "Item", value: "edit", label: "Edit", icon: "PencilIcon" }, + { _tag: "Separator" }, + { + _tag: "Item", + value: "delete", + label: "Delete", + icon: "TrashIcon", + variant: "destructive", + isDisabled: model.deletingChainId === chain.id, + }, + ] + const usage = + ruleNames.length === 0 + ? "Not used by any rule" + : `Used by ${ruleNames.length} ${pluralize(ruleNames.length, "rule")}` + return h.li( + [h.Key(chain.id), h.Class("px-4 py-3")], + [ + hstack(h, { gap: "3", align: "start" }, [ + icon(h, "BellIcon", { className: "mt-0.5 shrink-0 text-muted-foreground" }), + vstack(h, { gap: "0.5", shrink: true, grow: true }, [ + typography(h, { size: "sm", weight: "medium", truncate: true, as: "p" }, [chain.name]), + typography( + h, + { + size: "xs", + tone: "muted", + as: "p", + className: "line-clamp-2 break-words", + attributes: [h.Title(summary)], + }, + [summary], + ), + ]), + hstack(h, { gap: "2", className: "shrink-0" }, [ + typography( + h, + { + size: "xs", + tone: "muted", + numeric: true, + className: "hidden whitespace-nowrap sm:inline", + attributes: + ruleNames.length === 0 + ? [] + : [...tooltipTrigger(h, Arr.join(ruleNames, ", "))], + }, + [usage], + ), + ...(canManage(model) + ? [ + rowActionsMenu(h, { + slotId: menuId, + model: model.menus[menuId] ?? Menu.init(menuId), + entries, + label: `Actions for ${chain.name}`, + toParentMessage: (message) => + Message.GotRowMenuMessage({ menuId, message }), + }), + ] + : []), + ]), + ]), + ], + ) +} + +const chainsEmpty = (h: H, model: Model): Html => + empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "BellIcon", { size: 18 })]), + emptyTitle(h, ["No escalation chains"]), + emptyDescription(h, [ + "A chain pages one set of destinations, waits for someone to acknowledge, then pages the next. A rule's severity tier can start one on top of its own destinations.", + ...(canManage(model) ? [] : [" Ask an org admin to create one."]), + ]), + ]), + ...(canManage(model) + ? [ + emptyActions(h, [ + button(h, { size: "sm", attributes: [h.OnClick(Message.ClickedNewChain())] }, [ + icon(h, "PlusIcon"), + "Create chain", + ]), + ]), + ] + : []), + ], + "py-12", + ) + +/** The chains list, or its empty, loading or failed state. */ +export const chainsList = ( + h: H, + model: Model, + chains: ReadonlyArray, + destinations: ReadonlyArray, +): Html => + chains.length === 0 + ? chainsEmpty(h, model) + : borderedBox(h, { overflow: "hidden" }, [ + h.ul( + [h.Class("divide-y")], + Arr.map(chains, (chain) => chainRow(h, model, chain, destinations)), + ), + ]) + +const chainsBody = (h: H, model: Model): Html => { + const destinations = model.destinations ?? [] + const loading = h.div([h.Class("space-y-3")], [skeleton(h, "h-16 w-full"), skeleton(h, "h-16 w-full")]) + return AsyncData.match(model.chains.data, { + onIdle: () => loading, + onLoading: () => loading, + onFailure: (failure) => + errorState(h, { + failure, + title: "Couldn't load escalation chains", + onRetry: Message.ClickedRetryChains(), + }), + onRefreshing: (chains) => chainsList(h, model, chains, destinations), + onStale: ({ data }) => chainsList(h, model, data, destinations), + onSuccess: (chains) => chainsList(h, model, chains, destinations), + }) +} + +const hasChains = (model: Model): boolean => + Option.exists(AsyncData.getData(model.chains.data), (chains) => chains.length > 0) + +/** "Escalation chains": the heading, its New chain button once one exists, and the list. */ +export const chainsSection = (h: H, model: Model): Html => + h.section( + [h.Class("space-y-4")], + [ + h.div( + [], + [ + sectionHeading(h, { + title: "Escalation chains", + actions: + canManage(model) && hasChains(model) + ? button( + h, + { + size: "sm", + variant: "outline", + attributes: [h.OnClick(Message.ClickedNewChain())], + }, + [icon(h, "PlusIcon"), "New chain"], + ) + : undefined, + }), + typography(h, { size: "sm", tone: "muted", as: "p" }, [ + "Page step by step until someone acknowledges. Acknowledging pauses a chain and resolving ends it.", + ]), + ], + ), + chainsBody(h, model), + ], + ) + +/** "Delete {chain}?": the 409 that names the rules holding it stays in the dialog. */ +export const deleteChainDialog = (h: H, model: Model): Html => { + const chain = Arr.findFirst( + Option.getOrElse(AsyncData.getData(model.chains.data), () => []), + (candidate) => candidate.id === model.confirmDeleteChainId, + ) + const name = Option.getOrElse( + Option.map(chain, (found) => found.name), + () => "escalation chain", + ) + return confirmDialog(h, { + slotId: "alerts-delete-chain", + model: model.deleteChainDialog, + toParentMessage: (message) => Message.GotDeleteChainDialogMessage({ message }), + title: `Delete ${name}?`, + description: "Escalations already running finish on their own copy. This cannot be undone.", + children: + model.deleteChainError === null + ? undefined + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't delete chain"]), + alertDescription(h, [model.deleteChainError]), + ]), + ], + confirmLabel: "Delete chain", + isPending: model.deletingChainId !== null, + onConfirm: Message.ClickedConfirmDeleteChain(), + }) +} diff --git a/apps/web/src/page/alerts/hub/view-routing.ts b/apps/web/src/page/alerts/hub/view-routing.ts index 5c2fd2f020..54b09bd18d 100644 --- a/apps/web/src/page/alerts/hub/view-routing.ts +++ b/apps/web/src/page/alerts/hub/view-routing.ts @@ -31,6 +31,7 @@ import { createHref } from "../shared/links" import { Message } from "./message" import { canManage, type Model } from "./model" import { destinationMenuId } from "./update" +import { chainsSection } from "./view-chains" import { relativeTime } from "../../kit/relative-time" import { sectionHeading } from "../../kit/section-heading" import { typography, typographyClass } from "@maple/views/ui/typography" @@ -78,6 +79,8 @@ export const routingTab = (h: H, model: Model, inputs: RoutingInputs): Html => { ], ), separator(h, "horizontal", undefined), + chainsSection(h, model), + separator(h, "horizontal", undefined), h.section( [h.Class("space-y-4")], [ @@ -456,7 +459,12 @@ const logRow = (h: H, model: Model, inputs: RoutingInputs, event: AlertDeliveryE const ev = eventTypeMeta[event.eventType] const status = deliveryStatusMeta[event.status] // Delivery events carry only the type; a `chat` destination's logo comes from its connector. - const chatConnector = inputs.destinations.find((d) => d.id === event.destinationId)?.chatConnector + const chatConnector = Option.getOrUndefined( + Option.flatMapNullishOr( + Arr.findFirst(inputs.destinations, (d) => d.id === event.destinationId), + (d) => d.chatConnector, + ), + ) return tableRow(h, [ tableCell(h, [ hstack(h, { gap: "1.5", as: "span" }, [ diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index a1ba9b7ba2..6bfed197a8 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -23,6 +23,9 @@ import { alertsEmptyState } from "./view-empty" import { inboxTab } from "./view-inbox" import { routingTab } from "./view-routing" import * as DestinationDialogView from "../destination/view" +import * as ChainDialogView from "../escalation-dialog/view" +import { deleteChainDialog } from "./view-chains" +import { CHAIN_DIALOG_ID } from "./update" import { rulesTab } from "./view-rules" import { deleteMonitorDialog, uptimeTab } from "./view-uptime" import { slosTab } from "./view-slos" @@ -314,6 +317,14 @@ export const view = Submodel.defineView((model, ), deleteDialog(h, model), deleteMonitorDialog(h, model), + deleteChainDialog(h, model), + h.submodel({ + slotId: CHAIN_DIALOG_ID, + model: model.chainDialog, + view: ChainDialogView.view, + viewInputs: { id: CHAIN_DIALOG_ID, destinations: model.destinations }, + toParentMessage: (message) => Message.GotChainDialogMessage({ message }), + }), h.submodel({ slotId: "alerts-destination", model: model.destinationDialog, diff --git a/apps/web/src/page/alerts/shared/escalation-chains.test.ts b/apps/web/src/page/alerts/shared/escalation-chains.test.ts new file mode 100644 index 0000000000..cbaaba121e --- /dev/null +++ b/apps/web/src/page/alerts/shared/escalation-chains.test.ts @@ -0,0 +1,81 @@ +import { Array as Arr, Option } from "effect" +import { describe, expect, it } from "vitest" +import { chainSummary, formatWait, runView } from "./escalation-chains" +import { chainDestinations, chainOf, runOf } from "./escalation-test-fixtures" + +const nameOf = (id: string) => + Option.getOrElse( + Option.map( + Arr.findFirst(chainDestinations, (row) => row.id === id), + (row) => row.name, + ), + () => "an unavailable destination", + ) + +describe("chainSummary", () => { + it("reads each step with the wait before it, and the repeats", () => { + const chain = chainOf({ + n: 1, + name: "Checkout on-call", + steps: [ + { destinations: [1], wait: 10 }, + { destinations: [2], wait: 15 }, + { destinations: [3], wait: 30 }, + ], + repeat: 2, + }) + expect(chainSummary(chain, nameOf)).toBe( + "Slack #alerts-api, then PagerDuty Checkout after 10m, then Email on-call after 15m; repeats twice", + ) + }) + + it("names two destinations of a step, shortens more, and says nothing of no repeats", () => { + const chain = chainOf({ + n: 2, + name: "Wide", + steps: [ + { destinations: [1, 2], wait: 90 }, + { destinations: [3, 4, 5], wait: 5 }, + ], + }) + expect(chainSummary(chain, nameOf)).toBe( + "Slack #alerts-api and PagerDuty Checkout, then Email on-call and 2 more after 1h 30m", + ) + }) +}) + +describe("formatWait", () => { + it("reads minutes, hours and a day", () => { + expect([formatWait(1), formatWait(60), formatWait(75), formatWait(1440)]).toEqual([ + "1m", + "1h", + "1h 15m", + "1d", + ]) + }) +}) + +describe("runView", () => { + it("an active run says where it is and when it pages next", () => { + const view = runView(runOf("active", { nextStepAt: "2026-10-01T14:25:00.000Z" }), false, "UTC") + expect(view.state).toBe("escalating") + expect(view.detail).toMatch(/^Step 2 of 3, pass 1 of 3, next page at .*2:25/u) + }) + + it("an acknowledged incident reads as paused even before the run itself pauses", () => { + expect(runView(runOf("active"), true, "UTC")).toMatchObject({ + state: "paused", + label: "Paused: acknowledged", + }) + expect(runView(runOf("acknowledged"), false, "UTC").state).toBe("paused") + }) + + it("an ended run says why it ended", () => { + expect(runView(runOf("exhausted"), false, "UTC")).toEqual({ + state: "finished", + label: "Finished", + detail: "Every step was sent", + }) + expect(runView(runOf("resolved"), true, "UTC").detail).toBe("Ended when the incident resolved") + }) +}) diff --git a/apps/web/src/page/alerts/shared/escalation-chains.ts b/apps/web/src/page/alerts/shared/escalation-chains.ts new file mode 100644 index 0000000000..8c4e5cf05e --- /dev/null +++ b/apps/web/src/page/alerts/shared/escalation-chains.ts @@ -0,0 +1,140 @@ +import type { AlertDestinationId, AlertIncidentId, EscalationChainId } from "@maple/domain/http" +import type { + V2AlertIncidentEscalation, + V2EscalationChain, + V2EscalationChainCreateParams, +} from "@maple/domain/http/v2" +import { formatAlertTime } from "@maple/web-data/alerts/form-utils" +import { Array as Arr, Effect } from "effect" +import { toLoadFailure } from "@maple/views/data/load-failure" +import { MapleApiV2Client } from "../../../platform/api-clients" +import { AppRoute, hrefOf } from "../../../route" + +// Escalation chain reads and writes as Effects the pages' Commands wrap, and how a chain and an +// incident's run read in one line. + +/** An org holds at most 100 chains, one v2 page. */ +export const CHAIN_LIST_LIMIT = 100 + +/** 409 on delete while a rule tier still names the chain. */ +export const CHAIN_IN_USE_CODE = "escalation_chain_in_use" + +export const listEscalationChains = Effect.gen(function* () { + const client = yield* MapleApiV2Client + const page = yield* client.escalationChains.list({ query: { limit: CHAIN_LIST_LIMIT } }) + return page.data +}).pipe(Effect.mapError(toLoadFailure)) + +/** Creates a chain, or replaces `editingId`'s fields with the form's. */ +export const saveEscalationChain = ( + editingId: EscalationChainId | null, + payload: V2EscalationChainCreateParams, +) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return editingId === null + ? yield* client.escalationChains.create({ payload }) + : yield* client.escalationChains.update({ + params: { id: editingId }, + payload: { ...payload, repeat_count: payload.repeat_count ?? 0 }, + }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const deleteEscalationChain = (id: EscalationChainId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + yield* client.escalationChains.delete({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +/** The incident's latest escalation run, from its v2 document. */ +export const retrieveIncidentEscalation = (id: AlertIncidentId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + const incident = yield* client.alertIncidents.retrieve({ params: { id } }) + return incident.escalation + }).pipe(Effect.mapError(toLoadFailure)) + +export const routingTabHref = (): string => hrefOf(AppRoute.Alerts({ search: { tab: "routing" } })) + +// SUMMARY + +/** `10m`, `1h`, `1h 30m`, `1d`. */ +export const formatWait = (minutes: number): string => { + if (minutes < 60) return `${minutes}m` + if (minutes === 1440) return "1d" + const hours = Math.floor(minutes / 60) + const rest = minutes % 60 + return rest === 0 ? `${hours}h` : `${hours}h ${rest}m` +} + +/** "A", "A and B", "A and 2 more". */ +const namesOf = (names: ReadonlyArray): string => + names.length <= 2 ? Arr.join(names, " and ") : `${names[0]} and ${names.length - 1} more` + +const repeatPhrase = (count: number): string => + count === 0 + ? "" + : count === 1 + ? "; repeats once" + : count === 2 + ? "; repeats twice" + : `; repeats ${count} times` + +/** + * One line for a chain: "Slack #alerts-api, then PagerDuty after 10m, then email after 15m; + * repeats twice". Each step waits for an ack before the next, so a step's delay is the one before it. + */ +export const chainSummary = ( + chain: Pick, + nameOf: (id: AlertDestinationId) => string, +): string => { + const parts = Arr.map(chain.steps, (step, index) => { + const names = namesOf(Arr.map(step.targets, (target) => nameOf(target.destination_id))) + const previous = index === 0 ? undefined : chain.steps[index - 1] + return previous === undefined ? names : `then ${names} after ${formatWait(previous.wait_minutes)}` + }) + return `${Arr.join(parts, ", ")}${repeatPhrase(chain.repeat_count)}` +} + +// INCIDENT RUN + +export type RunState = "escalating" | "paused" | "finished" + +export type RunView = Readonly<{ + state: RunState + /** "Escalating", "Paused: acknowledged", "Finished". */ + label: string + /** "Step 2 of 3, next page at 14:25", or why it ended. */ + detail: string +}> + +const ENDED = { + resolved: "Ended when the incident resolved", + exhausted: "Every step was sent", + superseded: "Replaced by the critical tier's chain", +} as const + +/** How an incident's run reads in the rail. An acknowledged incident pauses it before the scheduler's next tick does. */ +export const runView = ( + run: V2AlertIncidentEscalation, + isAcknowledged: boolean, + timeZone: string, +): RunView => { + const position = `Step ${run.step} of ${run.step_count}${run.pass_count > 1 ? `, pass ${run.pass} of ${run.pass_count}` : ""}` + if (run.status === "resolved" || run.status === "exhausted" || run.status === "superseded") + return { state: "finished", label: "Finished", detail: ENDED[run.status] } + if (run.status === "acknowledged" || isAcknowledged) + return { + state: "paused", + label: "Paused: acknowledged", + detail: `${position}. Resumes if the incident is unacknowledged`, + } + return { + state: "escalating", + label: "Escalating", + detail: + run.next_step_at === null + ? position + : `${position}, next page at ${formatAlertTime(run.next_step_at, timeZone)}`, + } +} diff --git a/apps/web/src/page/alerts/shared/escalation-test-fixtures.ts b/apps/web/src/page/alerts/shared/escalation-test-fixtures.ts new file mode 100644 index 0000000000..11b81c8ec4 --- /dev/null +++ b/apps/web/src/page/alerts/shared/escalation-test-fixtures.ts @@ -0,0 +1,113 @@ +import { encodePublicId, V2AlertIncidentEscalation, V2EscalationChain } from "@maple/domain/http/v2" +import type { AlertDestinationRow } from "@maple/web-data/collections/alerts" +import { Schema, Array as Arr, String as Str } from "effect" +import { destinationRow, NOW } from "./test-rows" + +// Escalation chains, their destinations and incident runs for the scene tests and `/lab/escalation`. + +const pad = (n: number) => Str.padStart(12, "0")(String(n)) +export const chainUuid = (n: number) => `0199a00e-0000-7000-8000-${pad(n)}` +export const chainDestinationUuid = (n: number) => `0199a001-0000-7000-8000-${pad(100 + n)}` + +const NAMES = [ + "Slack #alerts-api", + "PagerDuty Checkout", + "Email on-call", + "Discord #incidents", + "Webhook to status page", + "PagerDuty Payments", + "Slack #eng-leads", + "Email CTO", + "Microsoft Teams SRE", + "Opsgenie platform", +] as const + +/** Ten destinations, named after where they page. */ +export const chainDestinations: ReadonlyArray = Arr.map(NAMES, (name, index) => + destinationRow({ id: chainDestinationUuid(index + 1), name }), +) + +export type ChainSpec = Readonly<{ + n: number + name: string + description?: string | null + /** Each step's destinations, by `chainDestinationUuid` number, and its wait. */ + steps: ReadonlyArray; wait: number }>> + repeat?: number + ruleIds?: ReadonlyArray +}> + +const decodeChain = Schema.decodeUnknownSync(V2EscalationChain) +const iso = new Date(NOW).toISOString() + +export const chainOf = (spec: ChainSpec): V2EscalationChain => + decodeChain({ + id: encodePublicId("esc", chainUuid(spec.n)), + object: "escalation_chain", + name: spec.name, + description: spec.description ?? null, + steps: Arr.map(spec.steps, (step) => ({ + targets: Arr.map(step.destinations, (d) => ({ + type: "destination", + destination_id: encodePublicId("dest", chainDestinationUuid(d)), + })), + wait_minutes: step.wait, + })), + repeat_count: spec.repeat ?? 0, + rule_ids: Arr.map(spec.ruleIds ?? [], (id) => encodePublicId("alrt", id)), + created_at: iso, + updated_at: iso, + }) + +/** Three chains: an everyday one, one with a long name, one with every step it can hold. */ +export const populatedChains = (ruleIds: ReadonlyArray): ReadonlyArray => [ + chainOf({ + n: 1, + name: "Checkout on-call", + steps: [ + { destinations: [1], wait: 10 }, + { destinations: [2], wait: 15 }, + { destinations: [3], wait: 30 }, + ], + repeat: 2, + ruleIds: ruleIds.slice(0, 2), + }), + chainOf({ + n: 2, + name: "Payments platform after-hours escalation for the EU region with leadership on the last step", + steps: [ + { destinations: [6, 7], wait: 5 }, + { destinations: [8], wait: 60 }, + ], + ruleIds: ruleIds.slice(2, 3), + }), + chainOf({ + n: 3, + name: "Everyone, eventually", + steps: Arr.map(Arr.range(1, 10), (d) => ({ destinations: [d], wait: d * 5 })), + repeat: 5, + }), +] + +const decodeRun = Schema.decodeUnknownSync(V2AlertIncidentEscalation) + +export const runOf = ( + status: V2AlertIncidentEscalation["status"], + fields: Partial<{ step: number; stepsSent: number; nextStepAt: string | null }> = {}, +): V2AlertIncidentEscalation => + decodeRun({ + chain_id: encodePublicId("esc", chainUuid(1)), + chain_name: "Checkout on-call", + status, + step: fields.step ?? 2, + step_count: 3, + pass: 1, + pass_count: 3, + steps_sent: fields.stepsSent ?? 1, + next_step_at: + fields.nextStepAt === undefined + ? status === "active" + ? new Date(NOW + 10 * 60_000).toISOString() + : null + : fields.nextStepAt, + }) From 909b57c38ca63806659b01d9b5d6f16095257119 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 13:46:28 +0100 Subject: [PATCH 21/98] feat(alerts): pick an escalation chain on each rule tier A tier now needs a destination or a chain, as the API does, and the picker reads the chosen chain's steps under it with a link to create one. --- apps/web/src/page/alerts/editor/command.ts | 10 ++ apps/web/src/page/alerts/editor/derive.ts | 2 +- .../alerts/editor/editor-chains.scene.test.ts | 103 ++++++++++++++ apps/web/src/page/alerts/editor/form-steps.ts | 19 ++- apps/web/src/page/alerts/editor/init.ts | 10 +- apps/web/src/page/alerts/editor/message.ts | 3 +- apps/web/src/page/alerts/editor/model.ts | 4 +- apps/web/src/page/alerts/editor/plan.ts | 2 +- apps/web/src/page/alerts/editor/sub-steps.ts | 65 +++++++-- apps/web/src/page/alerts/editor/update.ts | 1 + .../page/alerts/editor/view-notifications.ts | 133 ++++++++++++++++-- apps/web/src/page/alerts/editor/view.ts | 3 +- .../web-data/src/alerts/form-utils.test.ts | 26 ++++ packages/web-data/src/alerts/form-utils.ts | 4 +- 14 files changed, 356 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/page/alerts/editor/editor-chains.scene.test.ts diff --git a/apps/web/src/page/alerts/editor/command.ts b/apps/web/src/page/alerts/editor/command.ts index c5c147c62e..2e26a8c597 100644 --- a/apps/web/src/page/alerts/editor/command.ts +++ b/apps/web/src/page/alerts/editor/command.ts @@ -17,6 +17,7 @@ import { AutocompleteLookup, autocompleteLookup } from "../../kit/autocomplete-l import { trackProduct } from "../../../data/analytics" import { readGrants } from "../../../data/session-tenant" import { fetchPreview } from "../shared/preview" +import { listEscalationChains } from "../shared/escalation-chains" import { Message } from "./message" export const GenerateDraftId = Command.define("GenerateAlertDraftId", { @@ -50,6 +51,15 @@ export const FetchEditorConnectors = Command.define("FetchAlertEditorConnectors" }).pipe(Effect.orElseSucceed(() => Message.CompletedFetchConnectors({ connectors: [] }))), }) +/** The org's escalation chains for the tier pickers; a failed read offers none, as the connectors do. */ +export const FetchEditorChains = Command.define("FetchAlertEditorChains", { + messages: [Message.CompletedFetchEscalationChains], + execute: listEscalationChains.pipe( + Effect.map((chains) => Message.CompletedFetchEscalationChains({ chains })), + Effect.orElseSucceed(() => Message.CompletedFetchEscalationChains({ chains: [] })), + ), +}) + /** The provider's traces facets: the scope pickers' services and environments. */ export const FetchTraceFacets = Command.define("FetchAlertEditorTraceFacets", { args: { key: Schema.String, window: TimeWindow }, diff --git a/apps/web/src/page/alerts/editor/derive.ts b/apps/web/src/page/alerts/editor/derive.ts index 60f4b78901..0832e80a93 100644 --- a/apps/web/src/page/alerts/editor/derive.ts +++ b/apps/web/src/page/alerts/editor/derive.ts @@ -106,7 +106,7 @@ export const validationIssues = (form: RuleForm): ReadonlyArray => { const intervalIssue = intervalWindowIssue(form) if (intervalIssue !== null) issues.push(intervalIssue) issues.push(...deriveRuleQueryIssues(form), ...deriveTierIssues(form)) - if (form.destinationIds.length === 0) issues.push("Who gets notified") + if (form.destinationIds.length === 0 && form.escalationChainId === null) issues.push("Who gets notified") return issues } diff --git a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts new file mode 100644 index 0000000000..4565814c11 --- /dev/null +++ b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts @@ -0,0 +1,103 @@ +import { OrgId } from "@maple/domain" +import { AlertDestinationId } from "@maple/domain/http" +import { Schema, Array as Arr } from "effect" +import { expect as expectView, given, Mount, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" +import * as Select from "@maple/views/ui/select" +import type { PageContext } from "../../contract" +import { chainDestinations, chainUuid, populatedChains } from "../shared/escalation-test-fixtures" +import { destinationRow, NOW, ORG, ruleRow } from "../shared/test-rows" +import { chainSelectIdOf } from "./form-steps" +import { init } from "./init" +import { Message, type Tier } from "./message" +import type { Model } from "./model" +import { saveIssues } from "./derive" +import { 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 names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) +const themeResolved = () => + Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })) +const run = (model: Model, messages: ReadonlyArray) => + Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) + +const CHAINS = populatedChains([]) +const issuesOf = (model: Model) => (model.form === null ? ["no draft"] : saveIssues(model.form, null)) +const pickChain = (tier: Tier, value: string) => + Message.GotSelectMessage({ + id: chainSelectIdOf(tier), + message: Select.Message.SelectedItem({ index: 1, value }), + }) + +/** A slow-p95 draft on the org's default destination, with the org's chains read. */ +const ready = () => { + const start = init({ _tag: "AlertsCreate", search: { template: "slow_p95" } }, context) + expect(names(start.commands)).toContain("FetchAlertEditorChains") + return run(start.model, [ + Message.GeneratedDraftId({ id: "6f1c2e3a-0000-4000-8000-000000000001" }), + Message.CompletedReadClock({ nowMs: NOW }), + Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow(), ...chainDestinations] }), + Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(2)] }), + Message.CompletedFetchEscalationChains({ chains: CHAINS }), + ]) +} +const withoutDestination = (model: Model) => + run(model, [ + Message.ClickedRemoveDestination({ + tier: "warning", + id: Schema.decodeSync(AlertDestinationId)(destinationRow().id), + }), + ]) + +describe("alert rule editor, escalation chains", () => { + test("each tier offers None and the org's chains, with a link to create one", () => { + scene( + { update: pageUpdate, view }, + given(ready()), + themeResolved(), + expectView(role("combobox", { name: "Escalation chain" })).toExist(), + expectView(role("link", { name: "Create a chain" })).toHaveAttr("href", "/alerts?tab=routing"), + ) + }) + + test("a tier with neither a destination nor a chain says so and cannot be saved", () => { + const model = withoutDestination(ready()) + expect(issuesOf(model)).toContain("Who gets notified") + expect(names(pageUpdate(model, Message.ClickedSave()).commands)).toEqual([]) + scene( + { update: pageUpdate, view }, + given(model), + themeResolved(), + expectView(text("Pick a destination or an escalation chain")).toExist(), + ) + }) + + test("a chain alone routes the tier, and its steps read under the picker", () => { + const model = run(withoutDestination(ready()), [pickChain("warning", chainUuid(1))]) + expect(model.form?.escalationChainId).toBe(chainUuid(1)) + expect(issuesOf(model)).not.toContain("Who gets notified") + expect(names(pageUpdate(model, Message.ClickedSave()).commands)).toEqual(["SaveAlertRule"]) + scene( + { update: pageUpdate, view }, + given(model), + themeResolved(), + expectView(text(/^Slack #alerts-api, then PagerDuty Checkout after 10m/u)).toExist(), + expectView(text("Pick a destination or an escalation chain")).not.toExist(), + ) + expect(run(model, [pickChain("warning", "none")]).form?.escalationChainId).toBeNull() + }) + + test("the critical tier needs a destination or a chain of its own", () => { + const tiered = run(ready(), [Message.ClickedAddCriticalTier()]) + expect(issuesOf(tiered)).toContain("Critical destinations") + const routed = run(tiered, [pickChain("critical", chainUuid(2))]) + expect(routed.form?.escalation?.escalationChainId).toBe(chainUuid(2)) + expect(issuesOf(routed)).not.toContain("Critical destinations") + }) +}) diff --git a/apps/web/src/page/alerts/editor/form-steps.ts b/apps/web/src/page/alerts/editor/form-steps.ts index f3c2ea3263..9318de3497 100644 --- a/apps/web/src/page/alerts/editor/form-steps.ts +++ b/apps/web/src/page/alerts/editor/form-steps.ts @@ -1,4 +1,9 @@ -import type { AlertDestinationId, AlertSeverity, AlertSignalType } from "@maple/domain/http" +import type { + AlertDestinationId, + AlertSeverity, + AlertSignalType, + EscalationChainId, +} from "@maple/domain/http" import { switchSignalKind } from "@maple/web-data/alerts/rule-modes" import { ALERT_TEMPLATES } from "../shared/templates" import { suggestCriticalThreshold } from "./derive" @@ -20,6 +25,18 @@ export const setEscalationField = ( ): RuleForm => form.escalation === null ? form : { ...form, escalation: { ...form.escalation, [field]: value } } +/** A tier's escalation chain select, and its "None" value. */ +export const chainSelectIdOf = (tier: Tier): string => `rule-chain-${tier}` +export const NO_CHAIN = "none" + +/** The escalation chain a tier starts, or none. */ +export const setTierChain = (form: RuleForm, tier: Tier, chainId: EscalationChainId | null): RuleForm => + tier === "warning" + ? { ...form, escalationChainId: chainId } + : form.escalation === null + ? form + : { ...form, escalation: { ...form.escalation, escalationChainId: chainId } } + /** `setKind`: the switch carries filters and the threshold's meaning, and may leave a notice. */ export const switchKind = (form: RuleForm, kind: SignalKind): { form: RuleForm; notice: string | null } => { const moved = switchSignalKind(asFormState(form), kind) diff --git a/apps/web/src/page/alerts/editor/init.ts b/apps/web/src/page/alerts/editor/init.ts index 54757ad7e2..de8a1ad7c1 100644 --- a/apps/web/src/page/alerts/editor/init.ts +++ b/apps/web/src/page/alerts/editor/init.ts @@ -10,7 +10,13 @@ import * as QueryPanel from "../../kit/query-builder/panel" import * as TimeRangePicker from "../../kit/time-range-picker" import * as DestinationDialog from "../destination/dialog" import * as ChatConnect from "../shared/chat-connect" -import { FetchEditorConnectors, GenerateDraftId, ReadEditorClock, ReadEditorGrants } from "./command" +import { + FetchEditorChains, + FetchEditorConnectors, + GenerateDraftId, + ReadEditorClock, + ReadEditorGrants, +} from "./command" import { Message } from "./message" import type { Model } from "./model" @@ -40,6 +46,7 @@ export const init = (route: CreateRoute, _context: PageContext): PageReturn message._tag === "CompletedLoadChartRuntime" ? Message.CompletedLoadChartRuntime() diff --git a/apps/web/src/page/alerts/editor/message.ts b/apps/web/src/page/alerts/editor/message.ts index 8e89016904..8efb9d9f7c 100644 --- a/apps/web/src/page/alerts/editor/message.ts +++ b/apps/web/src/page/alerts/editor/message.ts @@ -1,6 +1,6 @@ import * as Policy from "@maple/domain/policy" import { AlertDestinationId, AlertRulePreviewResponse, DashboardDocument } from "@maple/domain/http" -import { V2ChatConnector } from "@maple/domain/http/v2" +import { V2ChatConnector, V2EscalationChain } from "@maple/domain/http/v2" import { AlertDestinationRowSchema, AlertIncidentRowSchema, @@ -71,6 +71,7 @@ export const Message = defineMessageUnion({ CompletedReadClock: { nowMs: Schema.Number }, CompletedReadGrants: { grants: Schema.Array(Policy.PermissionName) }, CompletedFetchConnectors: { connectors: Schema.Array(V2ChatConnector) }, + CompletedFetchEscalationChains: { chains: Schema.Array(V2EscalationChain) }, CompletedFetchTraceFacets: { key: Schema.String, result: Schema.Result(TraceFacets, LoadFailure) }, CompletedFetchAutocomplete: { key: Schema.String, diff --git a/apps/web/src/page/alerts/editor/model.ts b/apps/web/src/page/alerts/editor/model.ts index 2047ef229b..229ec535ed 100644 --- a/apps/web/src/page/alerts/editor/model.ts +++ b/apps/web/src/page/alerts/editor/model.ts @@ -1,6 +1,6 @@ import * as Policy from "@maple/domain/policy" import { AlertRuleId, DashboardDocument } from "@maple/domain/http" -import { V2ChatConnector } from "@maple/domain/http/v2" +import { V2ChatConnector, V2EscalationChain } from "@maple/domain/http/v2" import { AlertDestinationRowSchema, AlertIncidentRowSchema, @@ -88,6 +88,8 @@ export const Model = Schema.Struct({ isDefaultDestinationApplied: Schema.Boolean, /** `useChatConnectors`: `null` until the list answers. */ connectors: Schema.NullOr(Schema.Array(V2ChatConnector)), + /** The org's escalation chains a tier can start; `null` until the list answers (empty if it failed). */ + escalationChains: Schema.NullOr(Schema.Array(V2EscalationChain)), /** The 24h window the autocomplete provider reads, and the preview's own lookback. */ autocompleteWindow: Schema.NullOr(TimeWindow), previewPreset: Schema.String, diff --git a/apps/web/src/page/alerts/editor/plan.ts b/apps/web/src/page/alerts/editor/plan.ts index e08e3f0cfb..a14a630f71 100644 --- a/apps/web/src/page/alerts/editor/plan.ts +++ b/apps/web/src/page/alerts/editor/plan.ts @@ -117,7 +117,7 @@ export const applyDefaultDestination = (model: Model): Model => { ...model, isDefaultDestinationApplied: true, form: - fallback !== null && form.destinationIds.length === 0 + fallback !== null && form.destinationIds.length === 0 && form.escalationChainId === null ? { ...form, destinationIds: [fallback] } : form, } diff --git a/apps/web/src/page/alerts/editor/sub-steps.ts b/apps/web/src/page/alerts/editor/sub-steps.ts index 9b0f9c7e16..0297095685 100644 --- a/apps/web/src/page/alerts/editor/sub-steps.ts +++ b/apps/web/src/page/alerts/editor/sub-steps.ts @@ -1,4 +1,4 @@ -import { AlertComparator, AlertSeverity, AlertSignalType } from "@maple/domain/http" +import { AlertComparator, AlertSeverity, AlertSignalType, EscalationChainId } from "@maple/domain/http" import { QueryEngineAlertReducer } from "@maple/domain/query-engine" import { Schema } from "effect" import { Command } from "foldkit" @@ -10,7 +10,14 @@ import type { PageContext } from "../../contract" import { applyQueryChange } from "../../kit/query-builder/change" import * as QueryPanel from "../../kit/query-builder/panel" import * as TimeRangePicker from "../../kit/time-range-picker" -import { setBuiltinSignal, setChips, setSeverity } from "./form-steps" +import { + chainSelectIdOf, + NO_CHAIN, + setBuiltinSignal, + setChips, + setSeverity, + setTierChain, +} from "./form-steps" import { AUTO_INTERVAL, intervalRadioValue } from "./interval" import { Message } from "./message" import { panelInputsOf } from "./where-inputs" @@ -25,6 +32,7 @@ const decodeComparator = Schema.decodeUnknownOption(AlertComparator) const decodeReducer = Schema.decodeUnknownOption(QueryEngineAlertReducer) const decodeSignal = Schema.decodeUnknownOption(AlertSignalType) const decodeSeverity = Schema.decodeUnknownOption(AlertSeverity) +const decodeChainId = Schema.decodeUnknownOption(EscalationChainId) /** The radio groups: built-in signal, severity and check interval; their value is the form's. */ export const radioStep = ( @@ -42,7 +50,9 @@ export const radioStep = ( ? model.form.severity : intervalRadioValue(model.form.evaluationIntervalMinutes) const next = RadioGroup.update(RadioGroup.init(current ?? undefined), message) - const commands = Command.mapMessages(next.commands ?? [], (inner) => Message.GotRadioMessage({ group, message: inner })) + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotRadioMessage({ group, message: inner }), + ) const value = next.model.maybeValue if (value._tag === "None" || value.value === current) return { model, commands } const edited = editForm(model, context, (form) => { @@ -64,14 +74,32 @@ const withCommands = (step: Return, commands: Return["commands"]): Return => ({ commands: [...(commands ?? []), ...(step.commands ?? [])], }) -export const selectStep = (model: Model, context: PageContext, id: string, message: Select.Message): Return => { +export const selectStep = ( + model: Model, + context: PageContext, + id: string, + message: Select.Message, +): Return => { const current = model.selects[id] ?? Select.init(id, { value: "", selectedIndex: null }) const next = Select.update(current, message) const lifted: Model = { ...model, selects: { ...model.selects, [id]: next.model } } - const commands = Command.mapMessages(next.commands ?? [], (inner) => Message.GotSelectMessage({ id, message: inner })) + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotSelectMessage({ id, message: inner }), + ) if (next.outMessage === undefined) return { model: lifted, commands } const value = next.outMessage.value const edited = editForm(lifted, context, (form) => { + const tier = + id === chainSelectIdOf("warning") + ? "warning" + : id === chainSelectIdOf("critical") + ? "critical" + : null + if (tier !== null) { + if (value === NO_CHAIN) return setTierChain(form, tier, null) + const chainId = decodeChainId(value) + return chainId._tag === "Some" ? setTierChain(form, tier, chainId.value) : form + } if (id === "rule-comparator") { const comparator = decodeComparator(value) return comparator._tag === "Some" ? { ...form, comparator: comparator.value } : form @@ -82,14 +110,25 @@ export const selectStep = (model: Model, context: PageContext, id: string, messa return withCommands(edited, commands) } -export const comboStep = (model: Model, context: PageContext, id: string, message: Combobox.Message): Return => { +export const comboStep = ( + model: Model, + context: PageContext, + id: string, + message: Combobox.Message, +): Return => { const current = model.combos[id] ?? Combobox.init(id, "multiple") const next = Combobox.update(current, message) const lifted: Model = { ...model, combos: { ...model.combos, [id]: next.model } } - const commands = Command.mapMessages(next.commands ?? [], (inner) => Message.GotComboMessage({ id, message: inner })) - if (next.outMessage === undefined || next.outMessage._tag !== "ChangedSelection") return { model: lifted, commands } + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotComboMessage({ id, message: inner }), + ) + if (next.outMessage === undefined || next.outMessage._tag !== "ChangedSelection") + return { model: lifted, commands } const selected = next.outMessage.selected - return withCommands(editForm(lifted, context, (form) => setChips(form, id, selected)), commands) + return withCommands( + editForm(lifted, context, (form) => setChips(form, id, selected)), + commands, + ) } /** The rule's one query panel; its edits land on `queryBuilderDraft`. */ @@ -112,14 +151,18 @@ export const chartStep = (model: Model, message: Chart.Message): Return => { const next = Chart.update(model.chart, message) return { model: { ...model, chart: next.model }, - commands: Command.mapMessages(next.commands ?? [], (inner) => Message.GotChartMessage({ message: inner })), + commands: Command.mapMessages(next.commands ?? [], (inner) => + Message.GotChartMessage({ message: inner }), + ), } } /** The preview-only lookback: it re-runs the preview, never the rule being saved. */ export const timePickerStep = (model: Model, message: TimeRangePicker.Message): Return => { const next = TimeRangePicker.update(model.timePicker, message) - const commands = Command.mapMessages(next.commands ?? [], (inner) => Message.GotTimePickerMessage({ message: inner })) + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotTimePickerMessage({ message: inner }), + ) const lifted = { ...model, timePicker: next.model } // TODO(alert-preview): keep a custom preview window too; only presets are stored here. if (next.outMessage?._tag !== "SelectedPreset") return { model: lifted, commands } diff --git a/apps/web/src/page/alerts/editor/update.ts b/apps/web/src/page/alerts/editor/update.ts index 050d03cffa..c18a15f945 100644 --- a/apps/web/src/page/alerts/editor/update.ts +++ b/apps/web/src/page/alerts/editor/update.ts @@ -82,6 +82,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedReadClock: ({ nowMs }) => readClock(model, context, nowMs), CompletedReadGrants: ({ grants }) => ({ model: { ...model, grants } }), CompletedFetchConnectors: ({ connectors }) => connectorsAnswered(model, connectors), + CompletedFetchEscalationChains: ({ chains }) => ({ model: { ...model, escalationChains: chains } }), GotChatConnectMessage: ({ message: inner }) => chatConnectStep(model, inner), CompletedFetchTraceFacets: ({ key, result }) => ({ model: { ...model, traceFacets: Keyed.accept(model.traceFacets, key, result) }, diff --git a/apps/web/src/page/alerts/editor/view-notifications.ts b/apps/web/src/page/alerts/editor/view-notifications.ts index f3f8463d83..be2b6024c6 100644 --- a/apps/web/src/page/alerts/editor/view-notifications.ts +++ b/apps/web/src/page/alerts/editor/view-notifications.ts @@ -1,6 +1,6 @@ -import { ALERT_TEMPLATE_VARIABLES } from "@maple/domain/http" +import { ALERT_TEMPLATE_VARIABLES, type AlertDestinationId, type EscalationChainId } from "@maple/domain/http" import { comparatorSupportsTiers } from "@maple/web-data/alerts/form-utils" -import { Struct, Array as Arr } from "effect" +import { Option, Struct, Array as Arr } from "effect" import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" import { button } from "@maple/views/ui/button" import { card } from "@maple/views/ui/card" @@ -19,7 +19,12 @@ import type { Model } from "./model" import { fieldStateOf, idsOf } from "./view-fields" import { destinationList, type NotifyView } from "./view-notify" import { typography, typographyClass } from "@maple/views/ui/typography" -import { hstack } from "@maple/views/ui/stack" +import { hstack, vstack } from "@maple/views/ui/stack" +import { textLink } from "@maple/views/ui/text-link" +import { chainSummary, routingTabHref } from "../shared/escalation-chains" +import { chainSelectIdOf, NO_CHAIN } from "./form-steps" +import type { Tier } from "./message" +import { formSelect } from "./view-select" // `NotificationsSection`: who gets notified, per tier, // and the optional message template. @@ -28,10 +33,23 @@ type H = HtmlBuilder /** What the section reads off the Model and the draft. */ type NotificationsView = NotifyView & - Pick + Pick< + Model, + | "destinations" + | "isSendingTest" + | "isMessageTemplateOpen" + | "mountedForm" + | "escalationChains" + | "selects" + > type NotificationsForm = Pick< RuleForm, - "comparator" | "escalation" | "destinationIds" | "notificationTitle" | "notificationBody" + | "comparator" + | "escalation" + | "destinationIds" + | "escalationChainId" + | "notificationTitle" + | "notificationBody" > const TITLE_PLACEHOLDER = "{{ event.emoji }} {{ rule.name }} — {{ event.label }}" @@ -55,6 +73,98 @@ const tierHeading = (h: H, severity: "warning" | "critical", text: string): Html ], ) +/** The chain select's options: None, the org's chains, and a saved one the list no longer has. */ +const chainOptions = (model: NotificationsView, chainId: EscalationChainId | null) => { + const chains = model.escalationChains ?? [] + const isMissing = chainId !== null && !Arr.some(chains, (chain) => chain.id === chainId) + return [ + { value: NO_CHAIN, label: "None" }, + ...(isMissing + ? [ + { + value: chainId, + label: model.escalationChains === null ? "Saved chain" : "Unavailable chain", + }, + ] + : []), + ...Arr.map(chains, (chain) => ({ value: chain.id, label: chain.name })), + ] +} + +/** `TierChainPicker`: the escalation chain a tier starts on top of its destinations. */ +const chainPicker = ( + h: H, + model: NotificationsView, + tier: Tier, + chainId: EscalationChainId | null, + destinationIds: ReadonlyArray, +): Html => { + const id = chainSelectIdOf(tier) + const ids = idsOf(id, false) + const value = chainId ?? NO_CHAIN + const state = fieldStateOf(value, undefined) + const destinations = destinationsOf(model) + const nameOf = (destinationId: AlertDestinationId) => + Option.getOrElse( + Option.map( + Arr.findFirst(destinations, (destination) => destination.id === destinationId), + (destination) => destination.name, + ), + () => "an unavailable destination", + ) + const selected = Arr.findFirst(model.escalationChains ?? [], (chain) => chain.id === chainId) + const note = Option.match(selected, { + onSome: (chain) => [ + typography(h, { size: "xs", tone: "muted", as: "p", className: "break-words" }, [ + chainSummary(chain, nameOf), + ]), + ], + onNone: () => + chainId === null && destinationIds.length === 0 + ? [ + typography(h, { size: "xs", tone: "destructive", as: "p" }, [ + "Pick a destination or an escalation chain", + ]), + ] + : [], + }) + return formSelect(h, model.selects[id], id, value, chainOptions(model, chainId), { + className: "h-9 w-full", + labelledBy: ids.label, + toView: (parts) => + field(h, { className: "items-stretch gap-1.5", state }, [ + hstack(h, { gap: "2", justify: "between" }, [ + fieldLabel(h, { ids, state, className: "text-xs" }, "Escalation chain"), + textLink( + h, + { + href: routingTabHref(), + tone: "muted", + className: "text-xs", + attributes: [h.Target("_blank"), h.Rel("noopener")], + }, + ["Create a chain"], + ), + ]), + ...parts, + ...note, + ]), + }) +} + +/** One tier's destinations, then the chain it starts. */ +const tierRouting = ( + h: H, + model: NotificationsView, + tier: Tier, + destinationIds: ReadonlyArray, + chainId: EscalationChainId | null, +): Html => + vstack(h, { gap: "3" }, [ + destinationList(h, model, tier, destinationIds, destinationsOf(model)), + chainPicker(h, model, tier, chainId, destinationIds), + ]) + /** `MessageTemplate`: collapsed until opened, or open when the rule has one. */ const messageTemplate = (h: H, model: NotificationsView, form: NotificationsForm): Html => { const hasTemplate = form.notificationTitle.length > 0 || form.notificationBody.length > 0 @@ -171,7 +281,7 @@ const notifications = (h: H, model: NotificationsView, form: NotificationsForm): ] : []), ...(escalation === null - ? [destinationList(h, model, "warning", form.destinationIds, destinations)] + ? [tierRouting(h, model, "warning", form.destinationIds, form.escalationChainId)] : [ h.div( [h.Class("mt-1 space-y-4")], @@ -180,12 +290,12 @@ const notifications = (h: H, model: NotificationsView, form: NotificationsForm): [h.Class("space-y-1.5")], [ tierHeading(h, "warning", "Warning"), - destinationList( + tierRouting( h, model, "warning", form.destinationIds, - destinations, + form.escalationChainId, ), ], ), @@ -197,12 +307,12 @@ const notifications = (h: H, model: NotificationsView, form: NotificationsForm): "critical", "Critical, on top of the warning destinations", ), - destinationList( + tierRouting( h, model, "critical", escalation.destinationIds, - destinations, + escalation.escalationChainId, ), ], ), @@ -255,6 +365,8 @@ const viewOf = stableSlice((model: Model) => "chatConnect", "chatConnectMenuId", "brokenLogos", + "escalationChains", + "selects", ]), ) const formOf = stableSlice((form: RuleForm) => @@ -262,6 +374,7 @@ const formOf = stableSlice((form: RuleForm) => "comparator", "escalation", "destinationIds", + "escalationChainId", "notificationTitle", "notificationBody", ]), diff --git a/apps/web/src/page/alerts/editor/view.ts b/apps/web/src/page/alerts/editor/view.ts index 0d84dc5904..19100c6c88 100644 --- a/apps/web/src/page/alerts/editor/view.ts +++ b/apps/web/src/page/alerts/editor/view.ts @@ -110,7 +110,8 @@ const evaluatedFormOf = stableSlice((form: RuleForm): RuleForm => ({ const surface = (h: H, model: Model, form: RuleForm, timeZone: string): ReadonlyArray => { const evaluated = evaluatedFormOf(form) const isNotifyMissing = - form.destinationIds.length === 0 || deriveTierIssues(form).includes("Critical destinations") + (form.destinationIds.length === 0 && form.escalationChainId === null) || + deriveTierIssues(form).includes("Critical destinations") return [ h.div( [h.Class(cn("mx-auto w-full space-y-10", RULE_FORM_MAX_WIDTH))], diff --git a/packages/web-data/src/alerts/form-utils.test.ts b/packages/web-data/src/alerts/form-utils.test.ts index c3d20698ac..37d9ad201a 100644 --- a/packages/web-data/src/alerts/form-utils.test.ts +++ b/packages/web-data/src/alerts/form-utils.test.ts @@ -7,6 +7,7 @@ import { AlertIncidentId, AlertRuleId, ChatWorkspaceId, + EscalationChainId, } from "@maple/domain/http" import { buildDestinationCreateParamsV2, @@ -18,6 +19,7 @@ import { defaultRuleForm, ruleFormIsGrouped, deriveRuleQueryIssues, + deriveTierIssues, domainThresholdToForm, formThresholdToDomain, formatSignalValue, @@ -202,6 +204,30 @@ describe("buildRuleCreateParamsV2", () => { }) }) +describe("critical tier routing", () => { + const tiered = (escalationChainId: EscalationChainId | null) => ({ + ...defaultRuleForm(), + name: "Tiered", + signalType: "error_rate" as const, + comparator: "gt" as const, + threshold: "5", + escalation: { threshold: "10", recoveryValue: "", destinationIds: [], escalationChainId }, + }) + + it("needs a destination or an escalation chain, as the API does", () => { + expect(deriveTierIssues(tiered(null))).toContain("Critical destinations") + const chainId = EscalationChainId.make("0199a00e-0000-7000-8000-000000000001") + expect(deriveTierIssues(tiered(chainId))).not.toContain("Critical destinations") + expect(buildRuleCreateParamsV2(tiered(chainId)).thresholds).toContainEqual( + expect.objectContaining({ + severity: "critical", + destination_ids: [], + escalation_chain_id: chainId, + }), + ) + }) +}) + describe("telegram destination params", () => { it("builds create params with a trimmed token and chat id", () => { const params = buildDestinationCreateParamsV2({ diff --git a/packages/web-data/src/alerts/form-utils.ts b/packages/web-data/src/alerts/form-utils.ts index 97af26bec9..be1c138031 100644 --- a/packages/web-data/src/alerts/form-utils.ts +++ b/packages/web-data/src/alerts/form-utils.ts @@ -492,7 +492,9 @@ export function deriveTierIssues(form: RuleFormState): string[] { issues.push("Critical recovery past warning") } recoveryIssue("Critical recovery value", escalation.threshold, escalation.recoveryValue) - if (escalation.destinationIds.length === 0) issues.push("Critical destinations") + // A chain routes the tier on its own, as the API allows. + if (escalation.destinationIds.length === 0 && escalation.escalationChainId === null) + issues.push("Critical destinations") } if (Str.trim(form.breachesOutOf).length > 0) { const outOf = Number(form.breachesOutOf) From e57fbf5a6dbfbbd94792a8ef4f06856ef2b63239 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 13:46:28 +0100 Subject: [PATCH 22/98] feat(alerts): the incident rail shows its escalation: escalating, paused or finished --- apps/web/src/page/alerts/incident/command.ts | 11 ++ .../incident-escalation.scene.test.ts | 101 ++++++++++++++++++ apps/web/src/page/alerts/incident/message.ts | 6 +- apps/web/src/page/alerts/incident/model.ts | 4 +- apps/web/src/page/alerts/incident/update.ts | 24 +++++ .../web/src/page/alerts/incident/view-rail.ts | 51 ++++++++- apps/web/src/page/alerts/incident/view.ts | 6 ++ 7 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts diff --git a/apps/web/src/page/alerts/incident/command.ts b/apps/web/src/page/alerts/incident/command.ts index 69606def4b..7ef35a5820 100644 --- a/apps/web/src/page/alerts/incident/command.ts +++ b/apps/web/src/page/alerts/incident/command.ts @@ -7,6 +7,7 @@ import { MapleApiV2Client } from "../../../platform/api-clients" import { listDeliveries } from "../shared/commands" import { listSlos } from "../shared/slo-commands" import { listMonitors } from "../shared/uptime-commands" +import { retrieveIncidentEscalation } from "../shared/escalation-chains" import { fetchCheckSummary } from "../shared/check-summary" import { fetchPreview } from "../shared/preview" import { Message } from "./message" @@ -80,6 +81,16 @@ export const CreateInvestigation = Command.define("CreateAlertInvestigation", { ), }) +export const FetchIncidentEscalation = Command.define("FetchAlertIncidentEscalation", { + args: { key: Schema.String, incidentId: AlertIncidentId }, + messages: [Message.CompletedFetchEscalation], + execute: ({ key, incidentId }) => + retrieveIncidentEscalation(incidentId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchEscalation({ key, result })), + ), +}) + /** An SLO incident's SLOs, to find the one whose burn-rate rule opened it. */ export const FetchIncidentSlos = Command.define("FetchAlertIncidentSlos", { args: { key: Schema.String }, diff --git a/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts b/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts new file mode 100644 index 0000000000..5f0626fc56 --- /dev/null +++ b/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts @@ -0,0 +1,101 @@ +import { OrgId } from "@maple/domain" +import type { V2AlertIncidentEscalation } from "@maple/domain/http/v2" +import { Result, Schema, Array as Arr } from "effect" +import { expect as expectView, given, Mount, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" +import type { PageContext } from "../../contract" +import { runOf } from "../shared/escalation-test-fixtures" +import { incidentRow, INCIDENT_ID, NOW, ORG, ruleRow } 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 names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) +const themeResolved = () => + Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })) +const route = { _tag: "AlertIncident" as const, incidentId: INCIDENT_ID, search: {} } +const run = (model: Model, messages: ReadonlyArray) => + Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) + +const synced = (row = incidentRow()) => + run(init(route, context).model, [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedIncidents({ orgId: ORG, rows: [row] }), + Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(2)] }), + ]) + +const withRun = (model: Model, run: V2AlertIncidentEscalation | null) => + pageUpdate( + model, + Message.CompletedFetchEscalation({ key: model.escalation.key ?? "", result: Result.succeed(run) }), + ).model + +describe("alert incident, escalation", () => { + test("the incident reads its escalation once, and again when it is acknowledged", () => { + const entered = run(init(route, context).model, [Message.ReadClock({ nowMs: NOW })]) + const first = pageUpdate(entered, Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow()] })) + expect(names(first.commands)).toContain("FetchAlertIncidentEscalation") + const same = pageUpdate(first.model, Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow()] })) + expect(names(same.commands)).not.toContain("FetchAlertIncidentEscalation") + const acked = pageUpdate( + first.model, + Message.UpdatedIncidents({ + orgId: ORG, + rows: [incidentRow({ acknowledged_at: "2026-10-01T11:58:00.000Z" })], + }), + ) + expect(names(acked.commands)).toContain("FetchAlertIncidentEscalation") + }) + + test("a running chain shows its step and when it pages next", () => { + scene( + { update: pageUpdate, view }, + given(withRun(synced(), runOf("active", { nextStepAt: "2026-10-01T12:10:00.000Z" }))), + themeResolved(), + expectView(role("heading", { name: "Escalation" })).toExist(), + expectView(text("Escalating")).toExist(), + expectView(text("Checkout on-call")).toExist(), + expectView(text(/^Step 2 of 3, pass 1 of 3, next page at /u)).toExist(), + expectView(text("1 step paged so far")).toExist(), + ) + }) + + test("an acknowledged incident pauses the chain", () => { + scene( + { update: pageUpdate, view }, + given( + withRun( + synced(incidentRow({ acknowledged_at: "2026-10-01T11:58:00.000Z" })), + runOf("active"), + ), + ), + themeResolved(), + expectView(text("Paused: acknowledged")).toExist(), + expectView(text(/Resumes if the incident is unacknowledged/u)).toExist(), + ) + }) + + test("a finished chain says why, and an incident without one shows no section", () => { + scene( + { update: pageUpdate, view }, + given(withRun(synced(), runOf("exhausted", { step: 3, stepsSent: 9 }))), + themeResolved(), + expectView(text("Finished")).toExist(), + expectView(text("Every step was sent")).toExist(), + expectView(text("9 steps paged")).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(withRun(synced(), null)), + themeResolved(), + expectView(role("heading", { name: "Escalation" })).not.toExist(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/incident/message.ts b/apps/web/src/page/alerts/incident/message.ts index 3595bb470b..b65d66cce4 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, V2Slo, V2UptimeMonitor } from "@maple/domain/http/v2" +import { V2AlertIncidentEscalation, V2Investigation, V2Slo, V2UptimeMonitor } from "@maple/domain/http/v2" import { AlertIncidentRowSchema, AlertRuleRowSchema, @@ -33,6 +33,10 @@ export const Message = defineMessageUnion({ key: Schema.String, result: Schema.Result(Schema.NullOr(V2Investigation), LoadFailure), }, + CompletedFetchEscalation: { + key: Schema.String, + result: Schema.Result(Schema.NullOr(V2AlertIncidentEscalation), LoadFailure), + }, CompletedFetchSlos: { key: Schema.String, result: Schema.Result(Schema.Array(V2Slo), LoadFailure) }, CompletedFetchMonitors: { key: Schema.String, diff --git a/apps/web/src/page/alerts/incident/model.ts b/apps/web/src/page/alerts/incident/model.ts index 1ad02afa89..01525045d3 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, V2Slo, V2UptimeMonitor } from "@maple/domain/http/v2" +import { V2AlertIncidentEscalation, V2Investigation, V2Slo, V2UptimeMonitor } from "@maple/domain/http/v2" import { AlertIncidentRowSchema, AlertRuleRowSchema, @@ -30,6 +30,8 @@ export const Model = Schema.Struct({ checks: KeyedSchema(CheckSummary, LoadFailure), deliveries: KeyedSchema(Schema.Array(AlertDeliveryEventDocument), LoadFailure), investigation: KeyedSchema(Schema.NullOr(V2Investigation), LoadFailure), + /** The incident's latest escalation run (`alertIncidents.retrieve`), re-read as the incident changes. */ + escalation: KeyedSchema(Schema.NullOr(V2AlertIncidentEscalation), LoadFailure), /** An SLO incident's link to its SLO: `slos.list`, read only for one. */ slos: KeyedSchema(Schema.Array(V2Slo), LoadFailure), /** An uptime incident's link to its monitor: `uptimeMonitors.list`, read only for one. */ diff --git a/apps/web/src/page/alerts/incident/update.ts b/apps/web/src/page/alerts/incident/update.ts index 7fe78fb1f2..4fc69b1f59 100644 --- a/apps/web/src/page/alerts/incident/update.ts +++ b/apps/web/src/page/alerts/incident/update.ts @@ -19,6 +19,7 @@ import { CreateInvestigation, FetchIncidentChecks, FetchIncidentDeliveries, + FetchIncidentEscalation, FetchIncidentInvestigation, FetchIncidentMonitors, FetchIncidentPreview, @@ -46,6 +47,7 @@ export const init = (route: IncidentRoute, _context: PageContext): Return => ({ checks: Keyed.idle(), deliveries: Keyed.idle(), investigation: Keyed.idle(), + escalation: Keyed.idle(), slos: Keyed.idle(), monitors: Keyed.idle(), isStartingInvestigation: false, @@ -87,7 +89,26 @@ const followData = (model: Model, context: PageContext): Return => const investigationKey = requestKey(context.orgId, "investigations.list", { query: { incident_kind: "alert", incident_id: incidentId, limit: 1 }, }) + // What moves an escalation: a new page, an ack, a severity change, the resolve. + const escalationKey = requestKey(context.orgId, "alertIncidents.retrieve", { + id: incidentId, + status: view.incident.status, + severity: view.incident.severity, + lastNotifiedAt: view.incident.lastNotifiedAt, + acknowledgedAt: view.incident.acknowledgedAt, + }) const steps: ReadonlyArray<{ patch: Partial; commands: Commands }> = [ + ...Option.toArray( + Option.map( + sameKey(model.escalation, escalationKey) + ? Option.none() + : Keyed.request(model.escalation, escalationKey, "Load"), + (escalation) => ({ + patch: { escalation }, + commands: [FetchIncidentEscalation({ key: escalationKey, incidentId })], + }), + ), + ), ...Option.toArray( Option.map( sameKey(model.deliveries, deliveriesKey) @@ -288,6 +309,9 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedFetchInvestigation: ({ key, result }) => ({ model: { ...model, investigation: Keyed.accept(model.investigation, key, result) }, }), + CompletedFetchEscalation: ({ key, result }) => ({ + model: { ...model, escalation: Keyed.accept(model.escalation, key, result) }, + }), CompletedFetchSlos: ({ key, result }) => ({ model: { ...model, slos: Keyed.accept(model.slos, key, result) }, }), diff --git a/apps/web/src/page/alerts/incident/view-rail.ts b/apps/web/src/page/alerts/incident/view-rail.ts index a305e3f24b..2410bc0291 100644 --- a/apps/web/src/page/alerts/incident/view-rail.ts +++ b/apps/web/src/page/alerts/incident/view-rail.ts @@ -1,5 +1,6 @@ import { pluralize } from "@maple/ui/lib/format" import { type AlertIncidentDocument, type AlertRuleDocument, isManagedSignalType } from "@maple/domain/http" +import type { V2AlertIncidentEscalation } from "@maple/domain/http/v2" import { formatWarehouseDateTime } from "@maple/query-engine" import { cn } from "@maple/ui/lib/utils" import { formatSignalValue } from "@maple/web-data/alerts/form-utils" @@ -10,9 +11,12 @@ import { AppRoute, hrefOf } from "../../../route" import { eyebrow } from "@maple/views/ui/eyebrow" import { statusDot } from "@maple/views/ui/status-dot" import { createHref, incidentHref, ruleHref } from "../shared/links" +import { routingTabHref, runView } from "../shared/escalation-chains" +import { textLink } from "@maple/views/ui/text-link" +import type { Tone } from "@maple/ui/lib/tone" import { isoMs } from "../shared/time" import { typography, typographyClass } from "@maple/views/ui/typography" -import { hstack } from "@maple/views/ui/stack" +import { hstack, vstack } from "@maple/views/ui/stack" import { Array as Arr } from "effect" // `IncidentRail`: the incident detail's side rail. @@ -275,6 +279,46 @@ const ruleSection = ( ) } +const RUN_TONE = { escalating: "warn", paused: "neutral", finished: "neutral" } as const satisfies Record< + ReturnType["state"], + Tone +> + +/** `EscalationSection`: the chain paging this incident, where it stands, and when it pages next. */ +export const escalationSection = ( + h: HtmlBuilder, + run: V2AlertIncidentEscalation, + incident: AlertIncidentDocument, + timeZone: string, +): Html => { + const view = runView(run, incident.acknowledgedAt !== null, timeZone) + return railSection( + h, + ["Escalation"], + textLink(h, { href: routingTabHref(), tone: "muted", className: "text-xs" }, ["View chains"]), + [ + vstack(h, { gap: "1.5" }, [ + hstack(h, { gap: "2" }, [ + statusDot(h, { tone: RUN_TONE[view.state] }), + typography(h, { size: "sm", weight: "medium" }, [view.label]), + ]), + typography( + h, + { size: "sm", truncate: true, as: "p", attributes: [h.Title(run.chain_name)] }, + [run.chain_name], + ), + typography(h, { size: "xs", tone: "muted", as: "p", numeric: true }, [view.detail]), + typography(h, { size: "xs", tone: "muted", as: "p", numeric: true }, [ + String(run.steps_sent), + " ", + pluralize(run.steps_sent, "step"), + view.state === "finished" ? " paged" : " paged so far", + ]), + ]), + ], + ) +} + export const incidentRail = ( h: HtmlBuilder, options: Readonly<{ @@ -284,6 +328,8 @@ export const incidentRail = ( ruleIncidents: ReadonlyArray window: Readonly<{ start: number; end: number }> now: number + escalation: V2AlertIncidentEscalation | null + timeZone: string }>, ): Html => { const { rule, incident, states } = options @@ -291,6 +337,9 @@ export const incidentRail = ( return h.aside( [h.Class("space-y-5 lg:border-l lg:pl-6")], [ + ...(options.escalation === null + ? [] + : [escalationSection(h, options.escalation, incident, options.timeZone)]), ...(isGrouped && rule !== null ? [ groupsSection( diff --git a/apps/web/src/page/alerts/incident/view.ts b/apps/web/src/page/alerts/incident/view.ts index 34c8ca54d7..855f7d9596 100644 --- a/apps/web/src/page/alerts/incident/view.ts +++ b/apps/web/src/page/alerts/incident/view.ts @@ -1,3 +1,4 @@ +import type { V2AlertIncidentEscalation } from "@maple/domain/http/v2" import type { AlertCheckDocument, AlertIncidentDocument, @@ -166,6 +167,8 @@ export type IncidentBodyInputs = Readonly<{ entries: ReadonlyArray isEntriesLoading: boolean isEntriesFailed: boolean + /** The incident's latest escalation run, once read; `null` without one. */ + escalation?: V2AlertIncidentEscalation | null /** An SLO or uptime incident's SLO or monitor page; its tab stands in until it is known. */ sourceHref?: string | null chartId: string @@ -262,6 +265,8 @@ export const incidentBody = (h: HtmlBuilder, inputs: IncidentBodyInputs ruleIncidents: inputs.ruleIncidents, window: inputs.range, now: inputs.now, + escalation: inputs.escalation ?? null, + timeZone: inputs.timeZone, }), ], ) @@ -294,6 +299,7 @@ const body = (h: H, model: Model, view: IncidentView, timeZone: string, now: num incident, rule, sourceHref: Option.getOrNull(incident.signalType === "uptime" ? monitorLink : sloLink), + escalation: AsyncData.getOrElse(model.escalation.data, () => null), ruleIncidents: view.ruleIncidents, states: ruleStatesOf(model, incident.ruleId), preview: groupPreviewOf(previewState.preview, incident), From e98730ce6062f46ca097bb0f1fc37ce73ba4f7d0 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 13:46:39 +0100 Subject: [PATCH 23/98] feat(lab): /lab/escalation for chains, the chain dialog, the tier picker and the incident rail --- apps/web/src/lab/entries/escalation.ts | 16 ++ apps/web/src/lab/escalation-lab.ts | 347 +++++++++++++++++++++++++ apps/web/src/lab/registry.ts | 8 + 3 files changed, 371 insertions(+) create mode 100644 apps/web/src/lab/entries/escalation.ts create mode 100644 apps/web/src/lab/escalation-lab.ts diff --git a/apps/web/src/lab/entries/escalation.ts b/apps/web/src/lab/entries/escalation.ts new file mode 100644 index 0000000000..462078241e --- /dev/null +++ b/apps/web/src/lab/entries/escalation.ts @@ -0,0 +1,16 @@ +import { defineShellLab } from "../shell-lab" +import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../escalation-lab" + +// `/lab/escalation`: escalation chains, their dialog, the rule tier picker and the incident rail (`?state=`). + +export const lab = defineShellLab("Escalation chains", { + Model, + Message, + Flags, + flags, + init, + update, + view, + currentPath: "/lab/escalation", + breadcrumbs, +}) diff --git a/apps/web/src/lab/escalation-lab.ts b/apps/web/src/lab/escalation-lab.ts new file mode 100644 index 0000000000..56d80d9222 --- /dev/null +++ b/apps/web/src/lab/escalation-lab.ts @@ -0,0 +1,347 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import type { LoadFailure } from "@maple/views/data/load-failure" +import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" +import { Command, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import * as Menu from "@maple/views/ui/menu" +import * as Select from "@maple/views/ui/select" +import * as ChainDialog from "../page/alerts/escalation-dialog/dialog" +import * as EditorMessage from "../page/alerts/editor/message" +import * as EditorModel from "../page/alerts/editor/model" +import { init as editorInit } from "../page/alerts/editor/init" +import { update as editorUpdate } from "../page/alerts/editor/update" +import { breadcrumbsOf as editorBreadcrumbs, view as editorView } from "../page/alerts/editor/view" +import { chainSelectIdOf } from "../page/alerts/editor/form-steps" +import * as HubMessage from "../page/alerts/hub/message" +import * as HubModel from "../page/alerts/hub/model" +import * as HubUpdate from "../page/alerts/hub/update" +import { view as hubView } from "../page/alerts/hub/view" +import * as IncidentMessage from "../page/alerts/incident/message" +import * as IncidentModel from "../page/alerts/incident/model" +import * as IncidentUpdate from "../page/alerts/incident/update" +import { breadcrumbsOf as incidentBreadcrumbs, view as incidentView } from "../page/alerts/incident/view" +import { + chainDestinations, + chainUuid, + populatedChains, + runOf, +} from "../page/alerts/shared/escalation-test-fixtures" +import { incidentRow, INCIDENT_ID, NOW, ORG, ruleId, ruleRow } from "../page/alerts/shared/test-rows" +import type { PageContext } from "../page/contract" +import type { Resources } from "../platform/resources" + +// `/lab/escalation`: escalation chains in the Routing tab, their dialog, a rule tier's chain picker +// and the incident rail, over fixtures. `?state=` picks one. + +export const LabState = Schema.Literals([ + "chains-empty", + "chains", + "dialog-new", + "dialog-edit", + "dialog-invalid", + "dialog-error", + "rule-picker", + "incident-escalating", + "incident-paused", + "incident-finished", +]) +export type LabState = typeof LabState.Type + +export const Flags = Schema.Struct({ state: LabState }) +export type Flags = typeof Flags.Type + +export const flags: Effect.Effect = Effect.sync(() => ({ + state: Option.getOrElse( + Schema.decodeUnknownOption(LabState)(new URLSearchParams(window.location.search).get("state")), + (): LabState => "chains", + ), +})) + +export const Model = Schema.Struct({ + state: LabState, + hub: HubModel.Model, + editor: EditorModel.Model, + incident: IncidentModel.Model, +}) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + GotHubMessage: { message: HubMessage.Message }, + GotEditorMessage: { message: EditorMessage.Message }, + GotIncidentMessage: { message: IncidentMessage.Message }, +}) +export type Message = typeof Message.Type + +type Return = Update.Return +type Step = Readonly<{ model: PM; commands?: ReadonlyArray> }> + +const CONTEXT: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNamespace: null, timeZone: "UTC" } +const GRANTS = Policy.grantsOf(["org:engineer"]) +const RULES = [ + ruleRow(1, { name: "Checkout error rate" }), + ruleRow(2, { name: "Checkout p95 latency" }), + ruleRow(3, { name: "Payments webhook failures" }), +] + +/** The pages' reads and writes are dropped: the fixture is all the data there is. */ +const keepLocal = (commands: ReadonlyArray> | undefined) => + Arr.filter(commands ?? [], (command) => !/^(Fetch|Toggle|Delete|Save|Read|Generate)/.test(command.name)) + +/** Runs messages through a page's update, keeping the local commands (a dialog's open animation). */ +const fold = ( + start: Step, + messages: ReadonlyArray, + step: (model: PM, message: PS) => Step, +): Step => + Arr.reduce(messages, { model: start.model, commands: keepLocal(start.commands) }, (current, message) => { + const next = step(current.model, message) + return { model: next.model, commands: [...(current.commands ?? []), ...keepLocal(next.commands)] } + }) + +const hubStep = (model: HubModel.Model, message: HubMessage.Message) => + HubUpdate.update(model, message, CONTEXT) +const dialog = (message: ChainDialog.Message) => HubMessage.Message.GotChainDialogMessage({ message }) + +const SAVE_FAILURE: LoadFailure = { + _tag: "@maple/http/errors/AlertValidationError", + type: "invalid_request_error", + code: "invalid_request", + title: "Invalid request", + message: "Step 2 names a destination that no longer exists.", + retryable: false, + recovery: "fix_request", +} + +const hubFor = (state: LabState): Step => { + const entered = HubUpdate.init({ _tag: "Alerts", search: { tab: "routing" } }, CONTEXT).model + const chains = state === "chains-empty" ? [] : populatedChains([ruleId(1), ruleId(2), ruleId(3)]) + const base = [ + HubMessage.Message.ReadClock({ nowMs: NOW }), + HubMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }), + HubMessage.Message.UpdatedDestinations({ orgId: ORG, rows: chainDestinations }), + HubMessage.Message.UpdatedRules({ orgId: ORG, rows: RULES }), + HubMessage.Message.CompletedFetchRoutingDeliveries({ + key: entered.routingDeliveries.key ?? "", + result: Result.succeed([]), + }), + HubMessage.Message.CompletedFetchChains({ + key: entered.chains.key ?? "", + result: Result.succeed(chains), + }), + ] + const firstStepKey = "s0" + const extra: ReadonlyArray = + state === "dialog-new" + ? [HubMessage.Message.ClickedNewChain()] + : state === "dialog-invalid" + ? [ + HubMessage.Message.ClickedNewChain(), + dialog(ChainDialog.Message.ChangedWait({ key: firstStepKey, value: "2000" })), + dialog(ChainDialog.Message.ClickedSave()), + ] + : state === "dialog-error" + ? [ + HubMessage.Message.GotRowMenuMessage({ + menuId: `chain:${chainUuid(1)}`, + message: Menu.Message.ClickedItem({ + index: 0, + item: { kind: "Item", value: "edit" }, + }), + }), + dialog(ChainDialog.Message.ClickedSave()), + dialog( + ChainDialog.Message.CompletedSave({ + openCount: 1, + result: Result.fail(SAVE_FAILURE), + }), + ), + ] + : state === "dialog-edit" + ? [ + HubMessage.Message.GotRowMenuMessage({ + menuId: `chain:${chainUuid(3)}`, + message: Menu.Message.ClickedItem({ + index: 0, + item: { kind: "Item", value: "edit" }, + }), + }), + ] + : [] + return fold({ model: entered }, [...base, ...extra], hubStep) +} + +const editorStep = (model: EditorModel.Model, message: EditorMessage.Message) => + editorUpdate(model, message, CONTEXT) + +const editorFor = (): Step => { + const start = editorInit({ _tag: "AlertsCreate", search: { template: "high_error_rate" } }, CONTEXT) + const pick = (tier: "warning" | "critical", value: string) => + EditorMessage.Message.GotSelectMessage({ + id: chainSelectIdOf(tier), + message: Select.Message.SelectedItem({ index: 1, value }), + }) + return fold( + start, + [ + EditorMessage.Message.GeneratedDraftId({ id: "6f1c2e3a-0000-4000-8000-000000000001" }), + EditorMessage.Message.CompletedReadClock({ nowMs: NOW }), + EditorMessage.Message.CompletedReadGrants({ grants: GRANTS }), + EditorMessage.Message.UpdatedDestinations({ orgId: ORG, rows: chainDestinations }), + EditorMessage.Message.UpdatedRules({ orgId: ORG, rows: RULES }), + EditorMessage.Message.CompletedFetchConnectors({ connectors: [] }), + EditorMessage.Message.CompletedFetchEscalationChains({ + chains: populatedChains([ruleId(1), ruleId(2), ruleId(3)]), + }), + EditorMessage.Message.ChangedField({ field: "name", value: "Checkout error rate" }), + EditorMessage.Message.ClickedAddCriticalTier(), + pick("warning", chainUuid(1)), + ], + editorStep, + ) +} + +const incidentStep = (model: IncidentModel.Model, message: IncidentMessage.Message) => + IncidentUpdate.update(model, message, CONTEXT) + +const incidentFor = (state: LabState): Step => { + const start = IncidentUpdate.init({ _tag: "AlertIncident", incidentId: INCIDENT_ID, search: {} }, CONTEXT) + const isPaused = state === "incident-paused" + const row = incidentRow({ + rule_name: "Checkout p95 latency", + acknowledged_at: isPaused ? new Date(NOW - 4 * 60_000).toISOString() : null, + acknowledged_by_name: isPaused ? "Dana Kim" : null, + }) + const synced = fold( + start, + [ + IncidentMessage.Message.ReadClock({ nowMs: NOW }), + IncidentMessage.Message.UpdatedIncidents({ orgId: ORG, rows: [row] }), + IncidentMessage.Message.UpdatedRules({ + orgId: ORG, + rows: [ruleRow(2, { name: "Checkout p95 latency" })], + }), + IncidentMessage.Message.UpdatedRuleStates({ orgId: ORG, rows: [] }), + ], + incidentStep, + ) + const run = + state === "incident-finished" + ? runOf("exhausted", { step: 3, stepsSent: 9 }) + : isPaused + ? runOf("acknowledged") + : runOf("active") + const settled = fold( + synced, + [ + IncidentMessage.Message.CompletedFetchEscalation({ + key: synced.model.escalation.key ?? "", + result: Result.succeed(run), + }), + IncidentMessage.Message.CompletedFetchDeliveries({ + key: synced.model.deliveries.key ?? "", + result: Result.succeed([]), + }), + IncidentMessage.Message.CompletedFetchInvestigation({ + key: synced.model.investigation.key ?? "", + result: Result.succeed(null), + }), + ], + incidentStep, + ) + return settled +} + +const isHub = (state: LabState) => + state === "chains" || state === "chains-empty" || Str.startsWith("dialog-")(state) +const isIncident = (state: LabState) => Str.startsWith("incident-")(state) + +export const init = (flags: Flags): Return => { + const hub = hubFor(flags.state) + const editor = editorFor() + const incident = incidentFor(flags.state) + return { + model: { state: flags.state, hub: hub.model, editor: editor.model, incident: incident.model }, + commands: [ + ...(isHub(flags.state) + ? Command.mapMessages(hub.commands ?? [], (message) => Message.GotHubMessage({ message })) + : []), + ...(flags.state === "rule-picker" + ? Command.mapMessages(editor.commands ?? [], (message) => + Message.GotEditorMessage({ message }), + ) + : []), + ...(isIncident(flags.state) + ? Command.mapMessages(incident.commands ?? [], (message) => + Message.GotIncidentMessage({ message }), + ) + : []), + ], + } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotHubMessage: ({ message: child }) => { + const next = HubUpdate.update(model.hub, child, CONTEXT) + return { + model: { ...model, hub: next.model }, + commands: Command.mapMessages(keepLocal(next.commands), (inner) => + Message.GotHubMessage({ message: inner }), + ), + } + }, + GotEditorMessage: ({ message: child }) => { + const next = editorUpdate(model.editor, child, CONTEXT) + return { + model: { ...model, editor: next.model }, + commands: Command.mapMessages(keepLocal(next.commands), (inner) => + Message.GotEditorMessage({ message: inner }), + ), + } + }, + GotIncidentMessage: ({ message: child }) => { + const next = IncidentUpdate.update(model.incident, child, CONTEXT) + return { + model: { ...model, incident: next.model }, + commands: Command.mapMessages(keepLocal(next.commands), (inner) => + Message.GotIncidentMessage({ message: inner }), + ), + } + }, + }) + +export const breadcrumbs = (model: Model) => + isHub(model.state) + ? [{ label: "Alerts" }] + : isIncident(model.state) + ? incidentBreadcrumbs(model.incident) + : editorBreadcrumbs(model.editor) + +export const view = (model: Model, h: HtmlBuilder): Html => + isHub(model.state) + ? h.submodel({ + slotId: "lab-escalation-hub", + model: model.hub, + view: hubView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: HubMessage.Message) => Message.GotHubMessage({ message }), + }) + : isIncident(model.state) + ? h.submodel({ + slotId: "lab-escalation-incident", + model: model.incident, + view: incidentView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: IncidentMessage.Message) => + Message.GotIncidentMessage({ message }), + }) + : h.submodel({ + slotId: "lab-escalation-editor", + model: model.editor, + view: editorView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: EditorMessage.Message) => + Message.GotEditorMessage({ message }), + }) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 4f4161505a..e5c5d24c0e 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -61,6 +61,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/escalation", + title: "Escalation chains", + description: + "The Routing tab's escalation chains empty and populated, the chain dialog new, editing, invalid and failing, a rule tier's chain picker, and the incident rail escalating, paused or finished (`?state=`).", + kind: "lab", + session: "none", + }, { path: "/lab/billing-orgs", title: "Billing orgs", From 21f887cb962c2c5dbad0bfec44d739cbe91fa986 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 13:48:06 +0100 Subject: [PATCH 24/98] test(web): a stored tier's escalation chain survives ruleToFormState and formThresholds --- .../web-data/src/alerts/form-utils.test.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/web-data/src/alerts/form-utils.test.ts b/packages/web-data/src/alerts/form-utils.test.ts index 4eab042c41..59d82ddfee 100644 --- a/packages/web-data/src/alerts/form-utils.test.ts +++ b/packages/web-data/src/alerts/form-utils.test.ts @@ -5,6 +5,7 @@ import { AlertDestinationDocument, AlertDestinationId, AlertIncidentId, + AlertRuleDocument, AlertRuleId, ChatWorkspaceId, EscalationChainId, @@ -20,6 +21,7 @@ import { deriveTierIssues, formThresholds, ruleFormIsGrouped, + ruleToFormState, deriveRuleQueryIssues, domainThresholdToForm, formThresholdToDomain, @@ -661,6 +663,72 @@ describe("escalation chains on tiers", () => { ).toContain("Critical destinations") }) + it("carries each stored tier's chain through the form unchanged", () => { + const iso = "2026-11-06T12:00:00.000Z" + const tiers = [ + { + severity: "warning", + value: 2, + upper: null, + recoveryValue: null, + destinationIds: [], + escalationChainId: warnChain, + }, + { + severity: "critical", + value: 5, + upper: null, + recoveryValue: null, + destinationIds: [], + escalationChainId: pageChain, + }, + ] + const rule = Schema.decodeUnknownSync(AlertRuleDocument)({ + id: "8d7c6b5a-4f3e-4d2c-9b1a-0f9e8d7c6b5a", + name: "Checkout errors", + notes: null, + notificationTemplate: null, + enabled: true, + severity: "critical", + serviceNames: ["checkout"], + excludeServiceNames: [], + environments: [], + tags: [], + groupBy: null, + signalType: "error_rate", + comparator: "gt", + threshold: 2, + thresholdUpper: null, + windowMinutes: 5, + minimumSampleCount: 0, + consecutiveBreachesRequired: 2, + breachesOutOf: null, + evaluationIntervalMinutes: null, + thresholds: tiers, + consecutiveHealthyRequired: 2, + renotifyIntervalMinutes: 30, + apdexThresholdMs: null, + queryBuilderDraft: null, + rawQuerySql: null, + rawQueryReducer: null, + destinationIds: [], + noDataBehavior: "skip", + lastEvaluationError: null, + lastEvaluationErrorKind: null, + slowedIntervalMinutes: null, + slowedAt: null, + lastEvaluatedAt: iso, + lastScheduledAt: iso, + createdAt: iso, + updatedAt: iso, + createdBy: "user_test", + updatedBy: "user_test", + }) + const [warning, critical] = formThresholds(ruleToFormState(rule)) + expect(warning?.escalation_chain_id).toBe(warnChain) + expect(critical?.escalation_chain_id).toBe(pageChain) + }) + it("saves each tier's chain on that tier", () => { const [warning, critical] = formThresholds(twoTiers) expect(warning).toMatchObject({ severity: "warning", escalation_chain_id: warnChain }) From 71737b0818195222f9c57c95687d47370fdc0c41 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 13:49:37 +0100 Subject: [PATCH 25/98] test(web): drop the tier routing test the chains branch already covers --- .../web-data/src/alerts/form-utils.test.ts | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/packages/web-data/src/alerts/form-utils.test.ts b/packages/web-data/src/alerts/form-utils.test.ts index ae01e6d07e..59d82ddfee 100644 --- a/packages/web-data/src/alerts/form-utils.test.ts +++ b/packages/web-data/src/alerts/form-utils.test.ts @@ -23,7 +23,6 @@ import { ruleFormIsGrouped, ruleToFormState, deriveRuleQueryIssues, - deriveTierIssues, domainThresholdToForm, formThresholdToDomain, formatSignalValue, @@ -208,30 +207,6 @@ describe("buildRuleCreateParamsV2", () => { }) }) -describe("critical tier routing", () => { - const tiered = (escalationChainId: EscalationChainId | null) => ({ - ...defaultRuleForm(), - name: "Tiered", - signalType: "error_rate" as const, - comparator: "gt" as const, - threshold: "5", - escalation: { threshold: "10", recoveryValue: "", destinationIds: [], escalationChainId }, - }) - - it("needs a destination or an escalation chain, as the API does", () => { - expect(deriveTierIssues(tiered(null))).toContain("Critical destinations") - const chainId = EscalationChainId.make("0199a00e-0000-7000-8000-000000000001") - expect(deriveTierIssues(tiered(chainId))).not.toContain("Critical destinations") - expect(buildRuleCreateParamsV2(tiered(chainId)).thresholds).toContainEqual( - expect.objectContaining({ - severity: "critical", - destination_ids: [], - escalation_chain_id: chainId, - }), - ) - }) -}) - describe("telegram destination params", () => { it("builds create params with a trimmed token and chat id", () => { const params = buildDestinationCreateParamsV2({ From c255bab668034ebe846c758b206a25bef4374ad0 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 13:57:50 +0100 Subject: [PATCH 26/98] fix(web): the last step's wait label reads the parsed repeat count and says when it is unused --- apps/web/src/page/alerts/escalation-dialog/dialog.ts | 3 ++- apps/web/src/page/alerts/escalation-dialog/view.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.ts index 494e719ced..af390f1688 100644 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.ts +++ b/apps/web/src/page/alerts/escalation-dialog/dialog.ts @@ -111,7 +111,8 @@ const isDescription = Schema.is(EscalationChainDescription) const isWait = Schema.is(EscalationWaitMinutes) const isRepeat = Schema.is(EscalationRepeatCount) -const numberOf = (raw: string): number | null => (Str.isEmpty(Str.trim(raw)) ? null : Number(Str.trim(raw))) +export const numberOf = (raw: string): number | null => + Str.isEmpty(Str.trim(raw)) ? null : Number(Str.trim(raw)) export const nameError = (name: string): Option.Option => Str.isEmpty(Str.trim(name)) diff --git a/apps/web/src/page/alerts/escalation-dialog/view.ts b/apps/web/src/page/alerts/escalation-dialog/view.ts index d1fe8f2b6e..e88a39ae78 100644 --- a/apps/web/src/page/alerts/escalation-dialog/view.ts +++ b/apps/web/src/page/alerts/escalation-dialog/view.ts @@ -31,6 +31,7 @@ import { Message, type Model, nameError, + numberOf, repeatError, type StepDraft, stepsError, @@ -147,9 +148,10 @@ const isOutOfRange = (raw: string, error: Option.Option) => raw !== "" & const waitLabel = (model: Model, index: number): string => index < model.steps.length - 1 ? "Then wait, in minutes" - : model.repeatCount !== "" && model.repeatCount !== "0" + : (numberOf(model.repeatCount) ?? 0) > 0 ? "Wait before repeating, in minutes" - : "Wait before the chain ends, in minutes" + : // The run ends as soon as the last step pages, so this wait only matters once it repeats. + "Wait before repeating, in minutes (unused unless the chain repeats)" const stepBlock = (h: H, model: Model, inputs: ViewInputs, step: StepDraft, index: number): Html => { const count = model.steps.length From bdf97049979363e0b0d01c2208469994d843d869 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:00:51 +0100 Subject: [PATCH 27/98] feat(alerts): page the on-call by email and chat direct message Escalation steps can now target a member or an on-call schedule next to destinations. At step time the schedule's on-call person is resolved (a gap pages nobody and is logged and marked on the span), users are deduped, and one email row plus one row per linked chat account is queued in alert_member_notifications in the step's transaction. A new AlertMemberNotifier drains that outbox in the standard lane after the destination queue: lease claims, skip once the incident is acked or resolved, bounded retries for transient failures. Chat transports gain directMessage (Slack posts to the user id, Discord opens a DM channel, Hazel refuses). Chains are validated against members and schedules, a paged schedule cannot be deleted, schedule documents list their chains, and the incident timeline (v2 events, get_incident_timeline) shows who was paged on which channel without the address. --- apps/ai/src/mcp/lib/escalation-chains.ts | 199 +- apps/ai/src/mcp/lib/oncall-schedules.ts | 11 + apps/ai/src/mcp/tool-descriptors.generated.ts | 8 +- .../mcp/tools/__tests__/alert-tools.test.ts | 30 + .../__tests__/escalation-chain-tools.test.ts | 71 + .../__tests__/oncall-schedule-tools.test.ts | 22 +- .../src/mcp/tools/create-escalation-chain.ts | 8 +- .../ai/src/mcp/tools/get-incident-timeline.ts | 101 +- .../src/mcp/tools/list-escalation-chains.ts | 16 +- .../src/mcp/tools/update-escalation-chain.ts | 6 +- .../v2/alchemy-provider.integration.test.ts | 7 + .../api/src/routes/v2/alert-incidents.http.ts | 11 + apps/api/src/routes/v2/alerts.http.test.ts | 73 + .../src/routes/v2/escalation-chains.http.ts | 28 +- apps/api/src/routes/v2/v2-test-support.ts | 23 +- apps/chat-bot/src/relay/settle.test.ts | 1 + apps/chat-bot/src/relay/turn.test.ts | 1 + .../alerts/AlertIncidentAckService.test.ts | 8 +- .../alerts/AlertIncidentAckService.ts | 87 +- .../services/alerts/AlertMemberNotifier.ts | 398 + .../alerts/AlertsService.delivery.test.ts | 1 + .../alerts/AlertsService.escalation.test.ts | 7 +- .../AlertsService.oncall-paging.test.ts | 587 + .../alerts/AlertsService.rules.test.ts | 1 + .../src/services/alerts/AlertsService.ts | 40 +- .../services/alerts/ChatAlertPoster.test.ts | 1 + .../src/services/alerts/ChatAlertPoster.ts | 235 +- .../alerts/EscalationChainsService.ts | 87 +- .../services/alerts/OnCallSchedulesService.ts | 80 +- .../services/alerts/alert-escalation-runs.ts | 172 +- .../alerts/alerts-service-test-support.ts | 9 +- .../alerts/delivery/transports/chat.ts | 10 +- .../integrations/ChatWorkspaceService.test.ts | 1 + .../src/services/org/OrganizationService.ts | 2 + .../src/connectors/discord/outbound.test.ts | 61 +- .../src/connectors/discord/outbound.ts | 73 +- .../src/connectors/hazel/outbound.test.ts | 13 + .../src/connectors/hazel/outbound.ts | 8 + .../src/connectors/slack/outbound.test.ts | 28 + .../src/connectors/slack/outbound.ts | 10 +- packages/chat-platform/src/driver.test.ts | 1 + packages/chat-platform/src/outbound.ts | 9 + .../migration.sql | 33 + .../snapshot.json | 19980 ++++++++++++++++ packages/db/src/tables/escalation-chains.ts | 64 +- packages/db/src/tables/index.ts | 3 +- packages/domain/src/http/alerts.ts | 32 +- packages/domain/src/http/escalation-chains.ts | 47 +- .../domain/src/http/v2/alert-incidents.ts | 22 +- .../domain/src/http/v2/escalation-chains.ts | 74 +- .../domain/src/http/v2/v2-contract.test.ts | 27 +- packages/domain/src/mcp-outputs/alerts.ts | 13 + .../src/mcp-outputs/escalation-chains.ts | 7 +- 53 files changed, 22568 insertions(+), 279 deletions(-) create mode 100644 packages/backend/src/services/alerts/AlertMemberNotifier.ts create mode 100644 packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts create mode 100644 packages/db/drizzle/20261106124130_alert_member_notifications/migration.sql create mode 100644 packages/db/drizzle/20261106124130_alert_member_notifications/snapshot.json diff --git a/apps/ai/src/mcp/lib/escalation-chains.ts b/apps/ai/src/mcp/lib/escalation-chains.ts index 4bbf440b24..5f8a66d568 100644 --- a/apps/ai/src/mcp/lib/escalation-chains.ts +++ b/apps/ai/src/mcp/lib/escalation-chains.ts @@ -1,18 +1,22 @@ /** Pieces the escalation chain MCP tools share: rows, step text, step input and error mapping. */ import { Array as Arr, Effect, Option, Schema } from "effect" -import type { - AlertDestinationDocument, - AlertForbiddenError, - OrgId, - AlertPersistenceError, - AlertValidationError, - EscalationChainDocument, - EscalationChainInUseError, - EscalationChainNotFoundError, +import { + UserId, + type AlertDestinationDocument, + type AlertForbiddenError, + type AlertMemberDirectoryUnavailableError, + type OrgId, + type AlertPersistenceError, + type AlertValidationError, + type EscalationChainDocument, + type EscalationChainInUseError, + type EscalationChainNotFoundError, + type EscalationTarget, } from "@maple/domain/http" import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" import type { EscalationChainRow } from "@maple/domain/mcp-outputs" import { AlertsService } from "@maple/backend/services/alerts/AlertsService" +import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" import { McpInvalidInputError } from "../tools/types" import { toMcpHttpError } from "./map-http-error" import * as P from "./params" @@ -21,12 +25,33 @@ import { doc } from "./tool-doc" type ChainRow = typeof EscalationChainRow.Type type DestinationRef = Pick -/** The steps a tool takes: each the destinations it notifies and the minutes it waits. */ +/** Names the rendered steps show; each part is empty when it could not be read. */ +export interface TargetDirectory { + readonly destinations: ReadonlyArray + readonly members: ReadonlyMap + readonly schedules: ReadonlyArray<{ readonly id: string; readonly name: string }> +} + +/** The steps a tool takes: each the destinations, people and schedules it pages, and its wait. */ export const StepsInput = Schema.Array( Schema.Struct({ - destination_ids: Schema.Array(Schema.String).annotate({ - description: "Destinations this step notifies, from list_alert_destinations (UUID or dest_…)", - }), + destination_ids: Schema.optionalKey( + Schema.Array(Schema.String).annotate({ + description: "Destinations this step notifies, from list_alert_destinations (UUID or dest_…)", + }), + ), + user_ids: Schema.optionalKey( + Schema.Array(Schema.String).annotate({ + description: + "Members this step pages by email and chat direct message (Maple user ids, user_…)", + }), + ), + schedule_ids: Schema.optionalKey( + Schema.Array(Schema.String).annotate({ + description: + "On-call schedules whose on-call person this step pages, from list_oncall_schedules (UUID or onc_…)", + }), + ), wait_minutes: Schema.Number.annotate({ description: "Minutes to wait for an acknowledgement before the next step, 1 to 1440", }), @@ -34,49 +59,117 @@ export const StepsInput = Schema.Array( ) export const stepsParameterDescription = - 'Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{"destination_ids":["dest_…"],"wait_minutes":10},{"destination_ids":["dest_…"],"wait_minutes":15}]. At most 10 steps of 10 destinations' + 'Steps in order, each paging its targets then waiting for an ack before the next, e.g. [{"schedule_ids":["onc_…"],"wait_minutes":10},{"user_ids":["user_…"],"destination_ids":["dest_…"],"wait_minutes":15}]. A step names at least one of destination_ids (alert destinations), user_ids (members, paged by email and chat DM) or schedule_ids (whoever is on call then). At most 10 steps of 10 targets' -/** The steps as the service schema takes them, destination ids decoded to internal ids. */ +const decodeUserId = Schema.decodeUnknownEffect(UserId) + +/** The steps as the service schema takes them, ids decoded to internal ids. */ export const toStepsRequest = (steps: typeof StepsInput.Type) => Effect.forEach(steps, (step) => - Effect.map( - Effect.forEach(step.destination_ids, P.decodeResourceId("alertDestination", "steps")), - (ids) => ({ - targets: Arr.map(ids, (destinationId) => ({ type: "destination" as const, destinationId })), - waitMinutes: step.wait_minutes, - }), - ), + Effect.gen(function* () { + const destinationIds = yield* Effect.forEach( + step.destination_ids ?? [], + P.decodeResourceId("alertDestination", "steps"), + ) + const userIds = yield* Effect.forEach(step.user_ids ?? [], (raw) => + decodeUserId(raw).pipe( + Effect.mapError( + () => + new McpInvalidInputError({ + message: `Not a user id: ${raw}`, + parameter: "steps", + }), + ), + ), + ) + const scheduleIds = yield* Effect.forEach( + step.schedule_ids ?? [], + P.decodeResourceId("oncallSchedule", "steps"), + ) + const targets: ReadonlyArray = [ + ...Arr.map(destinationIds, (destinationId) => ({ + type: "destination" as const, + destinationId, + })), + ...Arr.map(userIds, (userId) => ({ type: "user" as const, userId })), + ...Arr.map(scheduleIds, (scheduleId) => ({ type: "schedule" as const, scheduleId })), + ] + return { targets, waitMinutes: step.wait_minutes } + }), ) -/** Destination names for the rendered steps; a failed read only drops the names. */ -export const destinationDirectory = (orgId: OrgId) => +/** Names for the rendered steps; a failed read only drops the names. */ +export const targetDirectory = (orgId: OrgId) => Effect.gen(function* () { const alerts = yield* AlertsService - return yield* alerts.listDestinations(orgId).pipe( - Effect.map((response): ReadonlyArray => response.destinations), - Effect.orElseSucceed((): ReadonlyArray => []), + const schedules = yield* OnCallSchedulesService + const [destinations, members, scheduleList] = yield* Effect.all( + [ + alerts.listDestinations(orgId).pipe( + Effect.map((response): ReadonlyArray => response.destinations), + Effect.orElseSucceed((): ReadonlyArray => []), + ), + schedules.memberNames(orgId), + schedules.list(orgId).pipe( + Effect.map((response) => + Arr.map(response.schedules, (s) => ({ id: s.id, name: s.name })), + ), + Effect.orElseSucceed(() => []), + ), + ], + { concurrency: "unbounded" }, ) + return { destinations, members, schedules: scheduleList } satisfies TargetDirectory }) +const toTargetRow = ( + target: EscalationTarget, + directory: TargetDirectory, +): ChainRow["steps"][number]["targets"][number] => { + switch (target.type) { + case "destination": { + const known = Option.getOrUndefined( + Arr.findFirst(directory.destinations, (d) => d.id === target.destinationId), + ) + return { + kind: "destination", + id: target.destinationId, + name: known?.name ?? null, + type: known?.type ?? null, + } + } + case "user": + return { + kind: "user", + id: target.userId, + name: directory.members.get(target.userId) ?? null, + type: null, + } + case "schedule": + return { + kind: "schedule", + id: target.scheduleId, + name: Option.getOrNull( + Option.map( + Arr.findFirst(directory.schedules, (s) => s.id === target.scheduleId), + (s) => s.name, + ), + ), + type: null, + } + } +} + export const toEscalationChainRow = ( chain: EscalationChainDocument, - destinations: ReadonlyArray, + directory: TargetDirectory, ): ChainRow => ({ id: chain.id, publicId: encodePublicId(PublicIdPrefixes.escalationChain, chain.id), name: chain.name, description: chain.description, steps: Arr.map(chain.steps, (step) => ({ - targets: Arr.map(step.targets, (target) => { - const known = Option.getOrUndefined( - Arr.findFirst(destinations, (d) => d.id === target.destinationId), - ) - return { - destinationId: target.destinationId, - name: known?.name ?? null, - type: known?.type ?? null, - } - }), + targets: Arr.map(step.targets, (target) => toTargetRow(target, directory)), waitMinutes: step.waitMinutes, })), repeatCount: chain.repeatCount, @@ -92,14 +185,22 @@ const formatWait = (minutes: number): string => ? `${minutes / 60}h` : `${Math.floor(minutes / 60)}h ${minutes % 60}m` -const targetLabel = (target: ChainRow["steps"][number]["targets"][number]): string => - target.name === null - ? `destination ${target.destinationId}` - : target.type === null - ? target.name - : `${target.name} (${target.type})` +const targetLabel = (target: ChainRow["steps"][number]["targets"][number]): string => { + switch (target.kind) { + case "destination": + return target.name === null + ? `destination ${target.id}` + : target.type === null + ? target.name + : `${target.name} (${target.type})` + case "user": + return target.name === null ? `user ${target.id}` : target.name + case "schedule": + return `whoever is on call on ${target.name === null ? `schedule ${target.id}` : target.name}` + } +} -/** One line per step, e.g. `1. notify On-call (pagerduty), then wait 10m for an ack`. */ +/** One line per step, e.g. `1. notify On-call (pagerduty), Ana, then wait 10m for an ack`. */ export const formatSteps = (chain: ChainRow): string => Arr.join( Arr.map( @@ -131,8 +232,10 @@ export const chainNotFound = (chainId: string) => parameter: "chain_id", }) -const failWith = (pipeName: string) => (error: AlertForbiddenError | AlertPersistenceError) => - Effect.fail(toMcpHttpError(pipeName)(error)) +const failWith = + (pipeName: string) => + (error: AlertForbiddenError | AlertPersistenceError | AlertMemberDirectoryUnavailableError) => + Effect.fail(toMcpHttpError(pipeName)(error)) const invalidChain = (error: AlertValidationError) => Effect.fail( @@ -146,6 +249,7 @@ export const createChainErrors = { "@maple/http/errors/AlertValidationError": invalidChain, "@maple/http/errors/AlertForbiddenError": failWith("create_escalation_chain"), "@maple/http/errors/AlertPersistenceError": failWith("create_escalation_chain"), + "@maple/http/errors/AlertMemberDirectoryUnavailableError": failWith("create_escalation_chain"), } export const updateChainErrors = { @@ -154,6 +258,7 @@ export const updateChainErrors = { Effect.fail(chainNotFound(error.chainId)), "@maple/http/errors/AlertForbiddenError": failWith("update_escalation_chain"), "@maple/http/errors/AlertPersistenceError": failWith("update_escalation_chain"), + "@maple/http/errors/AlertMemberDirectoryUnavailableError": failWith("update_escalation_chain"), } export const deleteChainErrors = { diff --git a/apps/ai/src/mcp/lib/oncall-schedules.ts b/apps/ai/src/mcp/lib/oncall-schedules.ts index 961d24fcdc..ef7992829e 100644 --- a/apps/ai/src/mcp/lib/oncall-schedules.ts +++ b/apps/ai/src/mcp/lib/oncall-schedules.ts @@ -12,6 +12,7 @@ import type { OnCallRestriction, OnCallRotation, OnCallScheduleDocument, + OnCallScheduleInUseError, OnCallScheduleNotFoundError, OnCallShiftDocument, OrgId, @@ -203,6 +204,16 @@ export const createOverrideErrors = { "@maple/http/errors/OnCallScheduleNotFoundError": notFound, } export const deleteScheduleErrors = { + "@maple/http/errors/OnCallScheduleInUseError": (error: OnCallScheduleInUseError) => + Effect.fail( + new McpInvalidInputError({ + message: `On-call schedule is still paged by escalation chains: ${Arr.join( + Arr.map(error.chainIds, (id, index) => `"${error.chainNames[index] ?? id}" (${id})`), + ", ", + )}. Remove it from their steps first with update_escalation_chain.`, + parameter: "schedule_id", + }), + ), "@maple/http/errors/AlertForbiddenError": failWith("delete_oncall_schedule"), "@maple/http/errors/AlertPersistenceError": failWith("delete_oncall_schedule"), "@maple/http/errors/OnCallScheduleNotFoundError": notFound, diff --git a/apps/ai/src/mcp/tool-descriptors.generated.ts b/apps/ai/src/mcp/tool-descriptors.generated.ts index 6c91ca7573..61eea02c08 100644 --- a/apps/ai/src/mcp/tool-descriptors.generated.ts +++ b/apps/ai/src/mcp/tool-descriptors.generated.ts @@ -33,14 +33,14 @@ 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"},"acknowledged":{"type":"boolean","description":"true: open incidents someone acknowledged; false: open incidents nobody has. Implies status=open"},"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"}]},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"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"},"acknowledged":{"type":"boolean"},"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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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)."},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of destination_ids, paging step by step until acknowledged (from list_escalation_chains): its UUID or its REST id (esc_\u2026)"},"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"}]},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"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"},"acknowledged":{"type":"boolean"},"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, and who escalation paged: 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"}]},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"anyOf":[{"type":"string"},{"type":"null"}]},"pages":{"type":"array","items":{"type":"object","properties":{"userId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"channel":{"type":"string"},"status":{"type":"string"},"at":{"type":"string"}},"required":["userId","name","channel","status","at"],"additionalProperties":true}}},"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"},"acknowledged":{"type":"boolean"},"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). escalation_chain_id adds step-by-step paging on top.","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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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."},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of destination_ids, paging step by step until acknowledged (from list_escalation_chains): its UUID or its REST id (esc_\u2026)"}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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}}"},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of the destinations (from list_escalation_chains), or \"none\" to detach it from every tier. On a rule with warning and critical tiers an id sets the critical tier: its UUID or its REST id (esc_\u2026)"}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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":"acknowledge_alert_incident","title":"Acknowledge Alert Incident","description":"Mark an open alert incident as acknowledged (someone is on it) or, with acknowledged=false, clear the acknowledgement. While acknowledged, reminders pause; an escalation to a higher severity still notifies and clears it. Only open incidents; ids from list_alert_incidents.","inputSchema":{"type":"object","properties":{"incident_id":{"type":"string","description":"Alert incident ID: its UUID or its REST id (inc_\u2026)"},"acknowledged":{"type":"boolean","description":"false to unacknowledge. Default true."}},"required":["incident_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"ruleId":{"type":"string"},"ruleName":{"type":"string"},"status":{"type":"string"},"acknowledged":{"type":"boolean"},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"anyOf":[{"type":"string"},{"type":"null"}]},"changed":{"type":"boolean"}},"required":["id","ruleId","ruleName","status","acknowledged","acknowledgedAt","acknowledgedBy","changed"],"additionalProperties":true},"annotations":{"title":"Acknowledge Alert Incident","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, - {"name":"list_escalation_chains","title":"List Escalation Chains","description":"List escalation chains: ordered paging steps that notify destinations one step at a time until an incident is acknowledged or resolved. Shows each chain's steps, repeats and the rules using it. Attach one to a rule with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chains":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["chains","total"],"additionalProperties":true},"annotations":{"title":"List Escalation Chains","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, - {"name":"create_escalation_chain","title":"Create Escalation Chain","description":"Create an escalation chain: steps that each notify their destinations, then wait for an acknowledgement before the next step. A rule tier that names the chain still notifies its own destinations at once; the chain pages on top until the incident is acknowledged or resolved. Attach it with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"Chain name, e.g. 'Checkout on-call'"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["destination_ids","wait_minutes"],"additionalProperties":false}},"description":"Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":10},{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. At most 10 steps of 10 destinations"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5 (default 0)"},"description":{"type":"string","description":"Free-form notes"}},"required":["name","steps"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Create Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, - {"name":"update_escalation_chain","title":"Update Escalation Chain","description":"Change an escalation chain's name, steps, repeats or description. Pass only what changes; steps replaces every step. Escalations already running finish on the steps they started with. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to change: its UUID or its REST id (esc_\u2026)"},"name":{"type":"string","description":"New name"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["destination_ids","wait_minutes"],"additionalProperties":false}},"description":"Replaces every step. Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":10},{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. At most 10 steps of 10 destinations"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5"},"description":{"type":"string","description":"New notes"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Update Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"list_escalation_chains","title":"List Escalation Chains","description":"List escalation chains: ordered paging steps that notify destinations one step at a time until an incident is acknowledged or resolved. Shows each chain's steps, repeats and the rules using it. Attach one to a rule with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chains":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"kind":{"type":"string","enum":["destination","user","schedule"]},"id":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["kind","id","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["chains","total"],"additionalProperties":true},"annotations":{"title":"List Escalation Chains","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"create_escalation_chain","title":"Create Escalation Chain","description":"Create an escalation chain: steps that each page their targets (alert destinations, members by email and chat DM, or whoever is on call on a schedule), then wait for an acknowledgement before the next step. A rule tier that names the chain still notifies its own destinations at once; the chain pages on top until the incident is acknowledged or resolved. Attach it with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"Chain name, e.g. 'Checkout on-call'"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"user_ids":{"type":"array","items":{"type":"string"},"description":"Members this step pages by email and chat direct message (Maple user ids, user_\u2026)"},"schedule_ids":{"type":"array","items":{"type":"string"},"description":"On-call schedules whose on-call person this step pages, from list_oncall_schedules (UUID or onc_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["wait_minutes"],"additionalProperties":false}},"description":"Steps in order, each paging its targets then waiting for an ack before the next, e.g. [{\"schedule_ids\":[\"onc_\u2026\"],\"wait_minutes\":10},{\"user_ids\":[\"user_\u2026\"],\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. A step names at least one of destination_ids (alert destinations), user_ids (members, paged by email and chat DM) or schedule_ids (whoever is on call then). At most 10 steps of 10 targets"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5 (default 0)"},"description":{"type":"string","description":"Free-form notes"}},"required":["name","steps"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"kind":{"type":"string","enum":["destination","user","schedule"]},"id":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["kind","id","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Create Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, + {"name":"update_escalation_chain","title":"Update Escalation Chain","description":"Change an escalation chain's name, steps, repeats or description. Pass only what changes; steps replaces every step. Escalations already running finish on the steps they started with. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to change: its UUID or its REST id (esc_\u2026)"},"name":{"type":"string","description":"New name"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"user_ids":{"type":"array","items":{"type":"string"},"description":"Members this step pages by email and chat direct message (Maple user ids, user_\u2026)"},"schedule_ids":{"type":"array","items":{"type":"string"},"description":"On-call schedules whose on-call person this step pages, from list_oncall_schedules (UUID or onc_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["wait_minutes"],"additionalProperties":false}},"description":"Replaces every step. Steps in order, each paging its targets then waiting for an ack before the next, e.g. [{\"schedule_ids\":[\"onc_\u2026\"],\"wait_minutes\":10},{\"user_ids\":[\"user_\u2026\"],\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. A step names at least one of destination_ids (alert destinations), user_ids (members, paged by email and chat DM) or schedule_ids (whoever is on call then). At most 10 steps of 10 targets"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5"},"description":{"type":"string","description":"New notes"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"kind":{"type":"string","enum":["destination","user","schedule"]},"id":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["kind","id","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Update Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"delete_escalation_chain","title":"Delete Escalation Chain","description":"Delete an escalation chain. Refused while a rule tier still names it: detach it with update_alert_rule escalation_chain_id=\"none\" first. Escalations already running finish. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to delete: its UUID or its REST id (esc_\u2026)"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"additionalProperties":true},"annotations":{"title":"Delete Escalation Chain","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}}, {"name":"list_oncall_schedules","title":"List On-Call Schedules","description":"List on-call schedules: layered rotations in a time zone that say who is on call when, with overrides on top. Shows each schedule's layers, participants and who is on call right now. Ask about one instant with who_is_on_call.","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"schedules":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"timeZone":{"type":"string"},"layers":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"participants":{"type":"array","items":{"type":"object","properties":{"userId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["userId","name"],"additionalProperties":true}},"rotation":{"type":"string"},"startsAt":{"type":"string"},"restrictions":{"type":"array","items":{"type":"string"}}},"required":["name","participants","rotation","startsAt","restrictions"],"additionalProperties":true}},"onCallNow":{"anyOf":[{"type":"object","properties":{"user":{"type":"object","properties":{"userId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["userId","name"],"additionalProperties":true},"startsAt":{"type":"string"},"endsAt":{"type":"string"}},"required":["user","startsAt","endsAt"],"additionalProperties":true},{"type":"null"}]},"chainIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","timeZone","layers","onCallNow","chainIds","createdAt","updatedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["schedules","total"],"additionalProperties":true},"annotations":{"title":"List On-Call Schedules","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"who_is_on_call","title":"Who Is On Call","description":"Who is on call for a schedule at an instant (default now), what put them there (a layer or an override), when their shift ends and who is next. Schedules from list_oncall_schedules, by id or name.","inputSchema":{"type":"object","properties":{"schedule":{"type":"string","description":"On-call schedule: its UUID, its REST id (onc_\u2026) or its name"},"at":{"type":"string","description":"The instant to ask about (default now), UTC (YYYY-MM-DD HH:mm:ss or ISO 8601)"}},"required":["schedule"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"scheduleId":{"type":"string"},"scheduleName":{"type":"string"},"timeZone":{"type":"string"},"at":{"type":"string"},"onCall":{"anyOf":[{"type":"object","properties":{"user":{"type":"object","properties":{"userId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["userId","name"],"additionalProperties":true},"startsAt":{"type":"string"},"endsAt":{"type":"string"},"source":{"type":"string"},"layerName":{"anyOf":[{"type":"string"},{"type":"null"}]},"overrideId":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["user","startsAt","endsAt","source","layerName","overrideId"],"additionalProperties":true},{"type":"null"}]},"next":{"anyOf":[{"type":"object","properties":{"user":{"type":"object","properties":{"userId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["userId","name"],"additionalProperties":true},"startsAt":{"type":"string"},"endsAt":{"type":"string"},"source":{"type":"string"},"layerName":{"anyOf":[{"type":"string"},{"type":"null"}]},"overrideId":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["user","startsAt","endsAt","source","layerName","overrideId"],"additionalProperties":true},{"type":"null"}]}},"required":["scheduleId","scheduleName","timeZone","at","onCall","next"],"additionalProperties":true},"annotations":{"title":"Who Is On Call","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, 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 9174752908..e9f7cdab6c 100644 --- a/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts @@ -34,6 +34,7 @@ import { import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" import { AlertsService } from "@maple/backend/services/alerts/AlertsService" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" +import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" import { CurrentMcpTenant } from "../../lib/query-warehouse" import { executeRegisteredMcpToolUnscoped } from "../registry" @@ -288,6 +289,34 @@ const layer = (seen: Seen) => }), ), } as never), + Layer.mock(AlertIncidentAckService)({ + listPages: (_org, incidentIds) => + Effect.succeed( + Arr.flatMap(incidentIds, (incidentId) => + incidentId === INCIDENT_ID + ? Arr.map( + [ + ["email", "sent"], + ["slack", "failed"], + ] as const, + ([channel, status], index) => ({ + id: `page-${index}`, + incidentId, + createdAt: Date.parse(NOW), + page: { + userId: Schema.decodeSync(UserId)("user_ben"), + name: "Ben Brook", + channel, + status, + errorMessage: + status === "failed" ? "messages_tab_disabled" : null, + }, + }), + ) + : [], + ), + ), + }), Layer.mock(EscalationChainsService)({ list: () => Effect.succeed( @@ -508,6 +537,7 @@ describe("alert tools", () => { const result = await ok("get_incident_timeline", { rule_id: RULE_ID, limit: 1 }, seen) expect(seen.incidentOptions).toMatchObject({ ruleId: RULE_ID, limit: 2 }) expect(text(result)).toContain(`Incident ID: ${INCIDENT_ID}`) + expect(text(result)).toContain("Paged: Ben Brook by email, Ben Brook by Slack DM (failed)") expect(text(result)).toContain("Showing 1 incidents.") const bad = await run("get_incident_timeline", { rule_id: "not-a-uuid" }) diff --git a/apps/ai/src/mcp/tools/__tests__/escalation-chain-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/escalation-chain-tools.test.ts index fc8168b302..ff31e1cf54 100644 --- a/apps/ai/src/mcp/tools/__tests__/escalation-chain-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/escalation-chain-tools.test.ts @@ -12,6 +12,7 @@ import { EscalationChainInUseError, EscalationChainNotFoundError, EscalationChainsListResponse, + OnCallSchedulesListResponse, OrgId, UserId, type EscalationChainCreateRequest, @@ -21,6 +22,7 @@ import { encodePublicId } from "@maple/domain/http/v2" import { ListEscalationChainsOutput } from "@maple/domain/mcp-outputs" import { AlertsService } from "@maple/backend/services/alerts/AlertsService" import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" +import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" import { CurrentMcpTenant } from "../../lib/query-warehouse" import { executeRegisteredMcpToolUnscoped } from "../registry" import type { McpToolResult } from "../types" @@ -30,6 +32,7 @@ const MISSING_ID = "9b8c7d6e-5f4a-4b3c-9d2e-1f0a9b8c7d6e" const RULE_ID = "7f1c2d3e-4b5a-4c6d-8e9f-0a1b2c3d4e5f" const PRIMARY = "0e1d2c3b-4a59-4687-9a0b-1c2d3e4f5a6b" const SECONDARY = "1f2e3d4c-5b6a-4798-8a0b-2c3d4e5f6a7b" +const SCHEDULE = "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d" const NOW = "2026-11-06T10:00:00.000Z" const chainDoc = (overrides: Partial = {}) => @@ -110,6 +113,38 @@ const layer = (seen: Seen) => : Effect.void }, }), + Layer.mock(OnCallSchedulesService)({ + memberNames: () => Effect.succeed(new Map([["user_ana", "Ana Alves"]])), + list: () => + Effect.succeed( + Schema.decodeSync(OnCallSchedulesListResponse)({ + schedules: [ + { + id: SCHEDULE, + name: "API primary", + description: null, + timeZone: "Europe/Berlin", + layers: [ + { + name: "Primary", + participants: ["user_ana"], + rotation: { + kind: "weekly", + handoffWeekday: "mon", + handoffTime: "09:00", + }, + startsAt: Date.parse(NOW), + }, + ], + onCallNow: null, + chainIds: [CHAIN_ID], + createdAt: NOW, + updatedAt: NOW, + }, + ], + }), + ), + }), Layer.mock(AlertsService)({ listDestinations: () => Effect.succeed( @@ -204,6 +239,42 @@ describe("escalation chain tools", () => { ) }) + it("create_escalation_chain pages members and on-call schedules, and names them in the steps", async () => { + const seen: Seen = {} + const result = await ok( + "create_escalation_chain", + { + name: "API on-call", + steps: JSON.stringify([ + { + schedule_ids: [encodePublicId("onc", SCHEDULE)], + user_ids: ["user_ana"], + wait_minutes: 5, + }, + { destination_ids: [PRIMARY], wait_minutes: 10 }, + ]), + }, + seen, + ) + expect(seen.created?.steps).toEqual([ + { + targets: [ + { type: "user", userId: "user_ana" }, + { type: "schedule", scheduleId: SCHEDULE }, + ], + waitMinutes: 5, + }, + { targets: [{ type: "destination", destinationId: PRIMARY }], waitMinutes: 10 }, + ]) + expect(text(result)).toContain("1. notify Ana Alves, whoever is on call on API primary, then wait 5m") + const badSchedule = await run( + "create_escalation_chain", + { name: "Bad", steps: [{ schedule_ids: ["dest_nope"], wait_minutes: 5 }] }, + {}, + ) + expect(invalidParameter(badSchedule)).toBe("steps") + }) + it("create_escalation_chain refuses bad steps before the service and maps an unknown destination to steps", async () => { const seen: Seen = {} const empty = await run("create_escalation_chain", { name: "Empty", steps: [] }, seen) diff --git a/apps/ai/src/mcp/tools/__tests__/oncall-schedule-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/oncall-schedule-tools.test.ts index 56446c676d..81c77713a2 100644 --- a/apps/ai/src/mcp/tools/__tests__/oncall-schedule-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/oncall-schedule-tools.test.ts @@ -12,7 +12,9 @@ import { OnCallOverrideNotFoundError, OnCallScheduleDocument, OnCallScheduleId, + OnCallScheduleInUseError, OnCallScheduleNotFoundError, + EscalationChainId, OnCallSchedulesListResponse, OnCallShiftsResponse, OrgId, @@ -72,6 +74,8 @@ interface Seen { onCallAt?: number /** The service refuses writes as an unknown member. */ rejectMember?: boolean + /** An escalation chain still pages the schedule. */ + inUse?: boolean } const rejectMember = () => { @@ -109,7 +113,20 @@ const layer = (seen: Seen) => }, delete: (_org, _roles, id) => { seen.deletedSchedule = id - return Effect.void + return seen.inUse === true + ? Effect.fail( + new OnCallScheduleInUseError({ + message: "in use", + scheduleId: Schema.decodeSync(OnCallScheduleId)(id), + chainIds: [ + Schema.decodeSync(EscalationChainId)( + "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b", + ), + ], + chainNames: ["API escalation"], + }), + ) + : Effect.void }, onCallAt: (_org, _id, atMs) => { seen.onCallAt = atMs @@ -367,5 +384,8 @@ describe("on-call schedule tools", () => { expect(text(result)).toContain("Checkout primary") const missing = await run("delete_oncall_schedule", { schedule_id: MISSING_ID }, {}) expect(invalidParameter(missing)).toBe("schedule_id") + const inUse = await run("delete_oncall_schedule", { schedule_id: SCHEDULE_ID }, { inUse: true }) + expect(invalidParameter(inUse)).toBe("schedule_id") + expect(inUse._tag === "Failure" ? inUse.failure.message : "").toContain('"API escalation"') }) }) diff --git a/apps/ai/src/mcp/tools/create-escalation-chain.ts b/apps/ai/src/mcp/tools/create-escalation-chain.ts index f1742a689d..9d271ff664 100644 --- a/apps/ai/src/mcp/tools/create-escalation-chain.ts +++ b/apps/ai/src/mcp/tools/create-escalation-chain.ts @@ -7,7 +7,7 @@ import { CurrentMcpTenant } from "../lib/query-warehouse" import { chainBlocks, createChainErrors, - destinationDirectory, + targetDirectory, StepsInput, stepsParameterDescription, toEscalationChainRow, @@ -23,7 +23,7 @@ export function registerCreateEscalationChainTool(server: McpToolRegistrar) { name: "create_escalation_chain", title: "Create Escalation Chain", description: - "Create an escalation chain: steps that each notify their destinations, then wait for an acknowledgement before the next step. " + + "Create an escalation chain: steps that each page their targets (alert destinations, members by email and chat DM, or whoever is on call on a schedule), then wait for an acknowledgement before the next step. " + "A rule tier that names the chain still notifies its own destinations at once; the chain pages on top until the incident is acknowledged or resolved. " + "Attach it with create_alert_rule / update_alert_rule escalation_chain_id.", parameters: Schema.Struct({ @@ -58,8 +58,8 @@ export function registerCreateEscalationChainTool(server: McpToolRegistrar) { const chain = yield* chains .create(tenant.orgId, tenant.userId, tenant.roles, request) .pipe(Effect.catchTags(createChainErrors)) - const destinations = yield* destinationDirectory(tenant.orgId) - return { chain: toEscalationChainRow(chain, destinations) } + const directory = yield* targetDirectory(tenant.orgId) + return { chain: toEscalationChainRow(chain, directory) } }), render: (output) => ({ title: "Escalation Chain Created", diff --git a/apps/ai/src/mcp/tools/get-incident-timeline.ts b/apps/ai/src/mcp/tools/get-incident-timeline.ts index d7a4e841b0..f938bed341 100644 --- a/apps/ai/src/mcp/tools/get-incident-timeline.ts +++ b/apps/ai/src/mcp/tools/get-incident-timeline.ts @@ -1,8 +1,9 @@ import { McpQueryError, type McpToolRegistrar } from "./types" -import { Effect, Schema, Array as Arr, String as Str } from "effect" +import { DateTime, Effect, 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 { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import { ackLabel, ALERT_INCIDENT_STATUSES, @@ -23,12 +24,40 @@ function formatTimestamp(iso: string | null): string | undefined { return Str.replace("T", " ")(Str.slice(0, 19)(iso)) } +const channelLabel = (channel: string): string => + channel === "email" ? "email" : `${Str.capitalize(channel)} DM` + +const pageStatus = (status: string): string => + status === "sent" + ? "" + : status === "skipped" + ? " (not sent: incident acknowledged or resolved)" + : ` (${status})` + +/** e.g. `Ben by email, Ben by Slack DM (failed)`; never the address. */ +const formatPages = ( + pages: ReadonlyArray<{ + readonly userId: string + readonly name: string | null + readonly channel: string + readonly status: string + }>, +): string => + Arr.join( + Arr.map( + pages, + (page) => + `${page.name ?? page.userId} by ${channelLabel(page.channel)}${pageStatus(page.status)}`, + ), + ", ", + ) + export function registerGetIncidentTimelineTool(server: McpToolRegistrar) { server.define({ 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.", + "Alert-rule incidents (not error-issue incidents) for one rule or all, with first and last trigger, last notification and resolution timestamps, and who escalation paged: the per-incident detail behind list_alert_incidents. Pass rule_id to follow one rule over time.", parameters: Schema.Struct({ rule_id: P.optionalResourceId( "alertRule", @@ -72,26 +101,57 @@ export function registerGetIncidentTimelineTool(server: McpToolRegistrar) { (params.group_key === undefined || i.groupKey === params.group_key), ) const incidents = matching.slice(0, params.limit) + // One read for every listed incident's pages. + const acks = yield* AlertIncidentAckService + const pages = yield* acks + .listPages( + tenant.orgId, + Arr.map(incidents, (i) => i.id), + ) + .pipe( + Effect.mapError( + (error) => + new McpQueryError({ + message: error.message, + pipeName: "get_incident_timeline", + cause: error, + }), + ), + ) return { - incidents: Arr.map(incidents, (i) => ({ - id: i.id, - ruleId: i.ruleId, - ruleName: i.ruleName, - groupKey: i.groupKey, - signalType: i.signalType, - severity: i.severity, - status: i.status, - comparator: i.comparator, - threshold: i.threshold, - lastObservedValue: i.lastObservedValue, - firstTriggeredAt: i.firstTriggeredAt, - lastTriggeredAt: i.lastTriggeredAt, - resolvedAt: i.resolvedAt, - lastNotifiedAt: i.lastNotifiedAt, - acknowledgedAt: i.acknowledgedAt, - acknowledgedBy: ackLabel(i), - })), + incidents: Arr.map(incidents, (i) => { + const paged = Arr.filter(pages, (entry) => entry.incidentId === i.id) + return { + id: i.id, + ruleId: i.ruleId, + ruleName: i.ruleName, + groupKey: i.groupKey, + signalType: i.signalType, + severity: i.severity, + status: i.status, + comparator: i.comparator, + threshold: i.threshold, + lastObservedValue: i.lastObservedValue, + firstTriggeredAt: i.firstTriggeredAt, + lastTriggeredAt: i.lastTriggeredAt, + resolvedAt: i.resolvedAt, + lastNotifiedAt: i.lastNotifiedAt, + acknowledgedAt: i.acknowledgedAt, + acknowledgedBy: ackLabel(i), + ...(paged.length === 0 + ? undefined + : { + pages: Arr.map(paged, (entry) => ({ + userId: entry.page.userId, + name: entry.page.name, + channel: entry.page.channel, + status: entry.page.status, + at: DateTime.formatIso(DateTime.makeUnsafe(entry.createdAt)), + })), + }), + } + }), total: incidents.length, openCount: Arr.filter(incidents, (i) => i.status === "open").length, resolvedCount: Arr.filter(incidents, (i) => i.status === "resolved").length, @@ -135,6 +195,7 @@ export function registerGetIncidentTimelineTool(server: McpToolRegistrar) { ? undefined : `${formatTimestamp(inc.acknowledgedAt)} by ${inc.acknowledgedBy ?? "someone"}`, ], + ["Paged", inc.pages === undefined ? undefined : formatPages(inc.pages)], ["Incident ID", inc.id], ["Rule ID", inc.ruleId], ]), diff --git a/apps/ai/src/mcp/tools/list-escalation-chains.ts b/apps/ai/src/mcp/tools/list-escalation-chains.ts index 11594e75b0..8349e7473a 100644 --- a/apps/ai/src/mcp/tools/list-escalation-chains.ts +++ b/apps/ai/src/mcp/tools/list-escalation-chains.ts @@ -4,12 +4,7 @@ import { ListEscalationChainsOutput } from "@maple/domain/mcp-outputs" import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" import { CurrentMcpTenant } from "../lib/query-warehouse" import { toMcpHttpError } from "../lib/map-http-error" -import { - destinationDirectory, - formatRepeats, - formatSteps, - toEscalationChainRow, -} from "../lib/escalation-chains" +import { targetDirectory, formatRepeats, formatSteps, toEscalationChainRow } from "../lib/escalation-chains" import { doc } from "../lib/tool-doc" export function registerListEscalationChainsTool(server: McpToolRegistrar) { @@ -29,13 +24,18 @@ export function registerListEscalationChainsTool(server: McpToolRegistrar) { const result = yield* chains .list(tenant.orgId) .pipe(Effect.mapError(toMcpHttpError("list_escalation_chains"))) - const destinations = result.chains.length === 0 ? [] : yield* destinationDirectory(tenant.orgId) + const directory = result.chains.length === 0 ? null : yield* targetDirectory(tenant.orgId) yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId, "result.rowCount": result.chains.length, }) return { - chains: Arr.map(result.chains, (chain) => toEscalationChainRow(chain, destinations)), + chains: Arr.map(result.chains, (chain) => + toEscalationChainRow( + chain, + directory ?? { destinations: [], members: new Map(), schedules: [] }, + ), + ), total: result.chains.length, } }), diff --git a/apps/ai/src/mcp/tools/update-escalation-chain.ts b/apps/ai/src/mcp/tools/update-escalation-chain.ts index df29eb0a9a..32ca903642 100644 --- a/apps/ai/src/mcp/tools/update-escalation-chain.ts +++ b/apps/ai/src/mcp/tools/update-escalation-chain.ts @@ -6,7 +6,7 @@ import { EscalationChainsService } from "@maple/backend/services/alerts/Escalati import { CurrentMcpTenant } from "../lib/query-warehouse" import { chainBlocks, - destinationDirectory, + targetDirectory, StepsInput, stepsParameterDescription, toEscalationChainRow, @@ -68,8 +68,8 @@ export function registerUpdateEscalationChainTool(server: McpToolRegistrar) { const chain = yield* chains .update(tenant.orgId, tenant.userId, tenant.roles, chainId, request) .pipe(Effect.catchTags(updateChainErrors)) - const destinations = yield* destinationDirectory(tenant.orgId) - return { chain: toEscalationChainRow(chain, destinations) } + const directory = yield* targetDirectory(tenant.orgId) + return { chain: toEscalationChainRow(chain, directory) } }), render: (output) => ({ title: "Escalation Chain Updated", diff --git a/apps/api/src/routes/v2/alchemy-provider.integration.test.ts b/apps/api/src/routes/v2/alchemy-provider.integration.test.ts index caac85a016..d2a77f925f 100644 --- a/apps/api/src/routes/v2/alchemy-provider.integration.test.ts +++ b/apps/api/src/routes/v2/alchemy-provider.integration.test.ts @@ -2,6 +2,7 @@ import { LiveActivitiesService } from "@maple/backend/services/push/LiveActiviti import { MobileDevicesService } from "@maple/backend/services/push/MobileDevicesService" import { ApnsClient } from "@maple/backend/platform/Apns" import { MobilePushService } from "@maple/backend/services/push/MobilePushService" +import { AlertMemberNotifier } from "@maple/backend/services/alerts/AlertMemberNotifier" import { ChatAlertPoster } from "@maple/backend/services/alerts/ChatAlertPoster" /** * Integration test for the `@maple-dev/alchemy` provider package @@ -186,6 +187,12 @@ const makeHarness = () => { alertDestinationsLive, alertReadModelsLive, alertRulesLive, + Layer.effect(AlertMemberNotifier, AlertMemberNotifier.make).pipe( + Layer.provide(ChatAlertPoster.layer), + Layer.provide( + Layer.mergeAll(envLive, testDb.layer, runtimeLive, emailLive, orgMembersLive), + ), + ), UptimeAlertSource.layer.pipe(Layer.provide(testDb.layer)), Layer.effect(SloAlertSource, SloAlertSource.make).pipe( Layer.provide(Layer.mergeAll(testDb.layer, warehouseLive)), diff --git a/apps/api/src/routes/v2/alert-incidents.http.ts b/apps/api/src/routes/v2/alert-incidents.http.ts index 9235babcda..03a4fb0900 100644 --- a/apps/api/src/routes/v2/alert-incidents.http.ts +++ b/apps/api/src/routes/v2/alert-incidents.http.ts @@ -54,6 +54,17 @@ const toV2Event = (doc: AlertIncidentEventDocument): V2AlertIncidentEvent => ({ ? null : { user_id: doc.actorUserId, name: doc.actorName }, source: doc.source, + ...(doc.page === undefined + ? undefined + : { + page: { + user_id: doc.page.userId, + name: doc.page.name, + channel: doc.page.channel, + status: doc.page.status, + error_message: doc.page.errorMessage, + }, + }), created_at: doc.createdAt, }) diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index 05701bbaf8..41d8a96cc9 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -2,6 +2,7 @@ import { LiveActivitiesService } from "@maple/backend/services/push/LiveActiviti import { MobileDevicesService } from "@maple/backend/services/push/MobileDevicesService" import { ApnsClient } from "@maple/backend/platform/Apns" import { MobilePushService } from "@maple/backend/services/push/MobilePushService" +import { AlertMemberNotifier } from "@maple/backend/services/alerts/AlertMemberNotifier" import { ChatAlertPoster } from "@maple/backend/services/alerts/ChatAlertPoster" import { afterEach, describe, expect, it } from "@effect/vitest" import { @@ -190,6 +191,12 @@ const makeHarness = ( alertDestinationsLive, alertReadModelsLive, alertRulesLive, + Layer.effect(AlertMemberNotifier, AlertMemberNotifier.make).pipe( + Layer.provide(ChatAlertPoster.layer), + Layer.provide( + Layer.mergeAll(envLive, testDb.layer, runtimeLive, emailLive, orgMembersLive), + ), + ), UptimeAlertSource.layer.pipe(Layer.provide(testDb.layer)), Layer.effect(SloAlertSource, SloAlertSource.make).pipe( Layer.provide(Layer.mergeAll(testDb.layer, warehouseLive)), @@ -1584,6 +1591,72 @@ describe("v2 alerts over HTTP", () => { await harness.dispose() }) + it("pages a schedule and a user from a chain step, and keeps a paged schedule from being deleted", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(["alerts:write"]) + const schedule = await harness.request( + "POST", + "/v2/alerts/oncall_schedules", + key.secret, + weeklySchedule, + ) + const scheduleId: string = schedule.body.id + const steps = [ + { + targets: [ + { type: "schedule", schedule_id: scheduleId }, + { type: "user", user_id: "user_chen" }, + ], + wait_minutes: 10, + }, + ] + const chain = await harness.request("POST", "/v2/alerts/escalation_chains", key.secret, { + name: "API on-call", + steps, + }) + expect(chain.status, JSON.stringify(chain.body)).toBe(200) + expect(chain.body.steps).toEqual(steps) + const fetched = await harness.request( + "GET", + `/v2/alerts/escalation_chains/${chain.body.id}`, + key.secret, + ) + expect(fetched.body.steps).toEqual(steps) + + const paged = await harness.request("GET", `/v2/alerts/oncall_schedules/${scheduleId}`, key.secret) + expect(paged.body.chain_ids).toEqual([chain.body.id]) + const inUse = await harness.request("DELETE", `/v2/alerts/oncall_schedules/${scheduleId}`, key.secret) + expect(inUse.status).toBe(409) + expect(inUse.body.error.code).toBe("oncall_schedule_in_use") + + const unknownSchedule = await harness.request("POST", "/v2/alerts/escalation_chains", key.secret, { + name: "Nowhere", + steps: [ + { + targets: [ + { + type: "schedule", + schedule_id: encodePublicId("onc", "0b8f7c1e-2f61-4f1e-9d2a-3c4b5d6e7f80"), + }, + ], + wait_minutes: 5, + }, + ], + }) + expect(unknownSchedule.status).toBe(400) + expect(unknownSchedule.body.error.message).toContain("on-call schedule") + + await harness.request("DELETE", `/v2/alerts/escalation_chains/${chain.body.id}`, key.secret) + const deleted = await harness.request( + "DELETE", + `/v2/alerts/oncall_schedules/${scheduleId}`, + key.secret, + ) + expect(deleted.body).toEqual({ id: scheduleId, object: "oncall_schedule", deleted: true }) + + await harness.dispose() + }) + it("rejects bad on-call times and layers with 400s, unknown schedules with 404 and read-only writes with 403", async () => { const harness = makeHarness() const key = await harness.bootstrapKey(["alerts:write"]) diff --git a/apps/api/src/routes/v2/escalation-chains.http.ts b/apps/api/src/routes/v2/escalation-chains.http.ts index 824bdab589..9d3b43efff 100644 --- a/apps/api/src/routes/v2/escalation-chains.http.ts +++ b/apps/api/src/routes/v2/escalation-chains.http.ts @@ -5,12 +5,14 @@ import { EscalationChainUpdateRequest, type EscalationChainDocument, type EscalationStep, + type EscalationTarget, } from "@maple/domain/http" import type { V2EscalationChain, V2EscalationChainDeleteResponse, V2EscalationChainUpdateParams, V2EscalationStep, + V2EscalationTarget, } from "@maple/domain/http/v2" import { MapleApiV2, paginateArray, timestamp } from "@maple/domain/http/v2" import { Array as Arr, Effect } from "effect" @@ -22,13 +24,35 @@ const chainAuditDiff = auditDiff { + switch (target.type) { + case "destination": + return { type: "destination", destination_id: target.destinationId } + case "user": + return { type: "user", user_id: target.userId } + case "schedule": + return { type: "schedule", schedule_id: target.scheduleId } + } +} + +const fromV2Target = (target: V2EscalationTarget): EscalationTarget => { + switch (target.type) { + case "destination": + return { type: "destination", destinationId: target.destination_id } + case "user": + return { type: "user", userId: target.user_id } + case "schedule": + return { type: "schedule", scheduleId: target.schedule_id } + } +} + const toV2Step = (step: EscalationStep): V2EscalationStep => ({ - targets: Arr.map(step.targets, (target) => ({ type: target.type, destination_id: target.destinationId })), + targets: Arr.map(step.targets, toV2Target), wait_minutes: step.waitMinutes, }) const fromV2Step = (step: V2EscalationStep): EscalationStep => ({ - targets: Arr.map(step.targets, (target) => ({ type: target.type, destinationId: target.destination_id })), + targets: Arr.map(step.targets, fromV2Target), waitMinutes: step.wait_minutes, }) diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index 484fea375d..b87e16d04d 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -188,13 +188,26 @@ const v2GroupLayersExceptOnboardingChecklist = ( HttpV2AlertDeliveriesLive, HttpV2AlertRulesLive, HttpV2AlertDestinationsLive, - // Real ack service: it needs only the Database. API keys are named by their label, so the - // empty member directory is never asked for one. + // Real ack service over the Database. API keys are named by their label, so the + // empty member directory is never asked for one; pages it names go unnamed. HttpV2AlertIncidentsLive.pipe( - Layer.provide(Layer.merge(AlertIncidentAckService.layer, OrgMembersServiceStubLayer)), + Layer.provide( + Layer.merge( + Layer.effect(AlertIncidentAckService, AlertIncidentAckService.make).pipe( + Layer.provide(OrgMembersServiceStubLayer), + ), + OrgMembersServiceStubLayer, + ), + ), + ), + // Real service; the stub member directory accepts every paged user. + HttpV2EscalationChainsLive.pipe( + Layer.provide( + Layer.effect(EscalationChainsService, EscalationChainsService.make).pipe( + Layer.provide(OrgMembersServiceStubLayer), + ), + ), ), - // Real service: it needs only the Database every harness already provides. - HttpV2EscalationChainsLive.pipe(Layer.provide(EscalationChainsService.layer)), // Real service; the empty member directory accepts every participant. HttpV2OnCallSchedulesLive.pipe( Layer.provide( diff --git a/apps/chat-bot/src/relay/settle.test.ts b/apps/chat-bot/src/relay/settle.test.ts index bedf92a90a..2db79179a7 100644 --- a/apps/chat-bot/src/relay/settle.test.ts +++ b/apps/chat-bot/src/relay/settle.test.ts @@ -108,6 +108,7 @@ const platform = (prefix = "m"): Platform => { whisper: () => Effect.die("a relayed turn answered a click"), typing: () => Effect.void, openThread: (request) => Effect.succeed(request.anchorMessageId), + directMessage: () => Effect.die("a settle opened a direct message"), conversation: () => Effect.succeed({ conversationKey: Schema.decodeSync( diff --git a/apps/chat-bot/src/relay/turn.test.ts b/apps/chat-bot/src/relay/turn.test.ts index 65811e75a6..8d4ba8bae5 100644 --- a/apps/chat-bot/src/relay/turn.test.ts +++ b/apps/chat-bot/src/relay/turn.test.ts @@ -134,6 +134,7 @@ const chat = ( }), typing: () => Effect.void, openThread: (request) => Effect.succeed(request.anchorMessageId), + directMessage: () => Effect.die("a turn opened a direct message"), conversation: (event) => Effect.sync(() => { // Only a mention opens a thread here, exactly as a real connector does: a message diff --git a/packages/backend/src/services/alerts/AlertIncidentAckService.test.ts b/packages/backend/src/services/alerts/AlertIncidentAckService.test.ts index 72a81e5c21..67fbf5ea35 100644 --- a/packages/backend/src/services/alerts/AlertIncidentAckService.test.ts +++ b/packages/backend/src/services/alerts/AlertIncidentAckService.test.ts @@ -3,7 +3,9 @@ import { AlertIncidentId, AlertRuleId, OrgId, UserId } from "@maple/domain/http" import { Duration, Effect, Layer, Schema, Array as Arr } from "effect" import { TestClock } from "effect/testing" import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@maple/backend/platform/test-pglite" +import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" import { AlertIncidentAckService, type AlertIncidentActor } from "./AlertIncidentAckService" +import { stubOrgMembersService } from "./alerts-service-test-support" const asOrgId = Schema.decodeUnknownSync(OrgId) const asRuleId = Schema.decodeUnknownSync(AlertRuleId) @@ -22,7 +24,11 @@ const slackClick: AlertIncidentActor = { userId: null, name: "sam", source: "sla const createdDbs: TestDb[] = [] afterEach(() => cleanupTestDbs(createdDbs)) -const makeLayer = (testDb: TestDb) => AlertIncidentAckService.layer.pipe(Layer.provide(testDb.layer)) +const makeLayer = (testDb: TestDb) => + Layer.effect(AlertIncidentAckService, AlertIncidentAckService.make).pipe( + Layer.provide(testDb.layer), + Layer.provide(Layer.succeed(OrgMembersService, stubOrgMembersService())), + ) const seed = (testDb: TestDb) => Effect.promise(() => diff --git a/packages/backend/src/services/alerts/AlertIncidentAckService.ts b/packages/backend/src/services/alerts/AlertIncidentAckService.ts index 4dc8e751ba..2b5196ceae 100644 --- a/packages/backend/src/services/alerts/AlertIncidentAckService.ts +++ b/packages/backend/src/services/alerts/AlertIncidentAckService.ts @@ -11,16 +11,18 @@ import { AlertIncidentNotFoundError, AlertIncidentResolvedError, type AlertIncidentDocument, + type AlertIncidentPage, type AlertIncidentId, type AlertPersistenceError, type OrgId, type UserId, } from "@maple/domain/http" import * as PG from "@maple-dev/effect-orm/postgres" -import { AlertIncidentEvents, AlertIncidents } from "@maple/db/tables" -import { Context, Effect, Layer, Option, Schema, Array as Arr } from "effect" +import { AlertIncidentEvents, AlertIncidents, AlertMemberNotifications } from "@maple/db/tables" +import { Context, Effect, Layer, Option, Order, Schema, Array as Arr } from "effect" import { Database } from "@maple/backend/platform/DatabaseLive" import { makeDbExecute } from "@maple/backend/platform/db-execute" +import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" import { makePersistenceError } from "./alert-persistence" import { AlertRuntime } from "./AlertRuntime" import { rowToIncidentDocument } from "./AlertReadModelsService" @@ -38,6 +40,16 @@ export interface AlertIncidentAckResult { readonly changed: boolean } +export interface AlertIncidentPageEntry { + readonly id: string + readonly incidentId: AlertIncidentId + readonly page: AlertIncidentPage + readonly createdAt: number +} + +/** Pages per incident the timeline shows; a chain pages at most a few people a step. */ +const MAX_PAGES_PER_READ = 500 + type AckError = AlertIncidentNotFoundError | AlertIncidentResolvedError | AlertPersistenceError export interface AlertIncidentAckServiceApi { @@ -51,6 +63,11 @@ export interface AlertIncidentAckServiceApi { incidentId: AlertIncidentId, actor: AlertIncidentActor, ) => Effect.Effect + /** People escalation steps paged for these incidents, oldest first; one read for all of them. */ + readonly listPages: ( + orgId: OrgId, + incidentIds: ReadonlyArray, + ) => Effect.Effect, AlertPersistenceError> /** The incident's activity, oldest first. */ readonly listEvents: ( orgId: OrgId, @@ -72,6 +89,7 @@ export class AlertIncidentAckService extends Context.Service< >()("@maple/backend/services/alerts/AlertIncidentAckService", { make: Effect.gen(function* () { const database = yield* Database + const orgMembers = yield* OrgMembersService const dbExecute = makeDbExecute(database, "AlertIncidentAckService", makePersistenceError) const findIncident = (orgId: OrgId, incidentId: AlertIncidentId) => @@ -180,6 +198,46 @@ export class AlertIncidentAckService extends Context.Service< return { incident: rowToIncidentDocument(current), changed: false } }) + const listPages = Effect.fn("AlertIncidentAckService.listPages")(function* ( + orgId: OrgId, + incidentIds: ReadonlyArray, + ) { + if (incidentIds.length === 0) return [] + const rows = yield* dbExecute((db) => + db.run( + PG.from(AlertMemberNotifications) + .select( + "id", + "incidentId", + "userId", + "channel", + "status", + "errorMessage", + "createdAt", + ) + .where(($) => [$.orgId.eq(orgId), $.incidentId.in_(...incidentIds)]) + .orderBy(["createdAt", "asc"], ["id", "asc"]) + .limit(MAX_PAGES_PER_READ), + ), + ) + if (rows.length === 0) return [] + // Display names only, never the address; a directory that cannot be read leaves them null. + const members = yield* orgMembers.listMembers(orgId).pipe(Effect.orElseSucceed(() => [])) + const names = new Map(Arr.map(members, (member) => [member.userId, member.name] as const)) + return Arr.map(rows, (row): AlertIncidentPageEntry => ({ + id: row.id, + incidentId: row.incidentId, + createdAt: row.createdAt, + page: { + userId: row.userId, + name: names.get(row.userId) ?? null, + channel: row.channel, + status: row.status, + errorMessage: row.errorMessage, + }, + })) + }) + const listEvents = Effect.fn("AlertIncidentAckService.listEvents")(function* ( orgId: OrgId, incidentId: AlertIncidentId, @@ -196,8 +254,9 @@ export class AlertIncidentAckService extends Context.Service< .limit(500), ), ) + const pages = yield* listPages(orgId, [incidentId]) // A type this build does not know (a newer writer) is skipped rather than failing the page. - return Arr.getSomes( + const acks = Arr.getSomes( Arr.map(Arr.reverse(rows), (row) => Option.map( decodeEventType(row.type), @@ -215,14 +274,34 @@ export class AlertIncidentAckService extends Context.Service< ), ), ) + const paged = Arr.map( + pages, + (entry) => + new AlertIncidentEventDocument({ + id: decodeEventId(entry.id), + incidentId: entry.incidentId, + type: "paged", + actorUserId: null, + actorName: null, + source: null, + page: entry.page, + createdAt: decodeIso(new Date(entry.createdAt).toISOString()), + }), + ) + // Stable: an ack and a page in the same millisecond keep the ack first. + return Arr.sort( + [...acks, ...paged], + Order.mapInput(Order.String, (event: AlertIncidentEventDocument) => event.createdAt), + ) }) return { acknowledge: setAcknowledged(true), unacknowledge: setAcknowledged(false), + listPages, listEvents, } satisfies AlertIncidentAckServiceApi }), }) { - static readonly layer = Layer.effect(this, this.make) + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(OrgMembersService.layer)) } diff --git a/packages/backend/src/services/alerts/AlertMemberNotifier.ts b/packages/backend/src/services/alerts/AlertMemberNotifier.ts new file mode 100644 index 0000000000..06248917f8 --- /dev/null +++ b/packages/backend/src/services/alerts/AlertMemberNotifier.ts @@ -0,0 +1,398 @@ +/** + * Drains `alert_member_notifications`, the pages escalation steps queued for people: email, or a + * direct message through the linked chat workspace. The standard lane runs it after the destination drain. + */ +import { alertDeliveryRetryDelayMs, canRetryAlertDelivery } from "@maple/alerting-core" +import { + AlertComparator, + AlertEventType, + AlertPersistenceError, + AlertSeverity, + AlertSignalType, + ChatWorkspaceId, + type AlertDeliveryFailure, + type OrgId, +} from "@maple/domain/http" +import * as PG from "@maple-dev/effect-orm/postgres" +import { AlertIncidents, AlertMemberNotifications, type AlertMemberNotificationRow } from "@maple/db/tables" +import { Array as Arr, Context, Effect, Exit, Layer, Option, Random, Schema } from "effect" +import { Database } from "@maple/backend/platform/DatabaseLive" +import { summarizeCause } from "@maple/backend/platform/describe-cause" +import { makeDbExecute } from "@maple/backend/platform/db-execute" +import { EmailService } from "@maple/backend/platform/EmailService" +import { Env } from "@maple/backend/platform/Env" +import { OrgMembersService, type OrgMember } from "@maple/backend/services/org/OrgMembersService" +import { buildAlertEmailContent } from "./alert-email" +import { makePersistenceError } from "./alert-persistence" +import { AlertRuntime } from "./AlertRuntime" +import { ChatAlertPoster } from "./ChatAlertPoster" +import { buildChatAlertBlocks, type ChatAlertCardInput } from "./delivery/transports/chat" + +const LEASE_TTL_MS = 30_000 +const DRAIN_LIMIT = 100 +const CONCURRENCY = 3 + +/** The stored step payload, read leniently: only what the email and the card show. */ +const MemberPayload = Schema.Struct({ + eventType: Schema.optionalKey(Schema.String), + dedupeKey: Schema.optionalKey(Schema.String), + linkUrl: Schema.optionalKey(Schema.String), + rule: Schema.optionalKey( + Schema.Struct({ + id: Schema.optionalKey(Schema.String), + name: Schema.optionalKey(Schema.String), + signalType: Schema.optionalKey(Schema.String), + severity: Schema.optionalKey(Schema.String), + groupKey: Schema.optionalKey(Schema.NullOr(Schema.String)), + comparator: Schema.optionalKey(Schema.String), + threshold: Schema.optionalKey(Schema.Number), + thresholdUpper: Schema.optionalKey(Schema.NullOr(Schema.Number)), + windowMinutes: Schema.optionalKey(Schema.Number), + }), + ), + observed: Schema.optionalKey( + Schema.Struct({ + value: Schema.optionalKey(Schema.NullOr(Schema.Number)), + sampleCount: Schema.optionalKey(Schema.NullOr(Schema.Number)), + detail: Schema.optionalKey(Schema.NullOr(Schema.String)), + }), + ), + chart: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + sparkline: Schema.optionalKey(Schema.String), + url: Schema.optionalKey(Schema.String), + }), + ), + ), + recipient: Schema.Struct({ + workspaceId: Schema.optionalKey(ChatWorkspaceId), + externalUserId: Schema.optionalKey(Schema.String), + }), +}) +type MemberPayload = Schema.Schema.Type + +const decodePayload = Schema.decodeUnknownOption(MemberPayload) +const decodeSeverity = Schema.decodeUnknownOption(AlertSeverity) +const decodeSignalType = Schema.decodeUnknownOption(AlertSignalType) +const decodeComparator = Schema.decodeUnknownOption(AlertComparator) +const decodeEventType = Schema.decodeUnknownOption(AlertEventType) + +/** What one attempt came to; `retryable` only matters for a failure. */ +type Attempt = + | { readonly status: "sent" } + | { readonly status: "failed"; readonly message: string; readonly retryable: boolean } + +const failure = (message: string, retryable: boolean): Attempt => ({ status: "failed", message, retryable }) + +const cardInput = ( + row: AlertMemberNotificationRow, + payload: MemberPayload, + appBaseUrl: string, + sentAtMs: number, +): ChatAlertCardInput => { + const rule = payload.rule + const fallbackLink = `${appBaseUrl}/alerts/incidents/${encodeURIComponent(row.incidentId)}` + return { + context: { + ruleId: rule?.id ?? "", + ruleName: rule?.name ?? "Alert", + groupKey: rule?.groupKey ?? null, + signalType: Option.getOrElse(decodeSignalType(rule?.signalType), () => "throughput" as const), + severity: Option.getOrElse(decodeSeverity(rule?.severity), () => "warning" as const), + comparator: Option.getOrElse(decodeComparator(rule?.comparator), () => "gt" as const), + threshold: rule?.threshold ?? 0, + thresholdUpper: rule?.thresholdUpper ?? null, + windowMinutes: rule?.windowMinutes ?? 5, + eventType: Option.getOrElse(decodeEventType(payload.eventType), () => "trigger" as const), + incidentId: row.incidentId, + incidentStatus: "open", + dedupeKey: payload.dedupeKey ?? row.dedupeKey, + value: payload.observed?.value ?? null, + sampleCount: payload.observed?.sampleCount ?? null, + detail: payload.observed?.detail ?? null, + sparkline: payload.chart?.sparkline ?? null, + chartUrl: payload.chart?.url ?? null, + sentAtMs, + }, + templated: null, + linkUrl: payload.linkUrl ?? fallbackLink, + } +} + +export interface AlertMemberNotifierApi { + /** Sends every due page; one bad row never stops the rest. */ + readonly drain: Effect.Effect< + { readonly processedCount: number; readonly failureCount: number }, + AlertPersistenceError + > +} + +export class AlertMemberNotifier extends Context.Service()( + "@maple/backend/services/alerts/AlertMemberNotifier", + { + make: Effect.gen(function* () { + const database = yield* Database + const env = yield* Env + const runtime = yield* AlertRuntime + const orgMembers = yield* OrgMembersService + const email = yield* EmailService + const chat = yield* ChatAlertPoster + const dbExecute = makeDbExecute(database, "AlertMemberNotifier", makePersistenceError) + + const claimable = ( + $: PG.ColumnAccessor, + nowMs: number, + ) => + PG.or( + PG.and($.status.eq("queued"), $.scheduledAt.lte(nowMs)), + PG.and($.status.eq("sending"), $.claimExpiresAt.isNotNull(), $.claimExpiresAt.lte(nowMs)), + ) + + const claim = (row: AlertMemberNotificationRow, nowMs: number) => + dbExecute((db) => + db.run( + PG.update(AlertMemberNotifications) + .set({ + status: "sending", + claimedAt: nowMs, + claimExpiresAt: nowMs + LEASE_TTL_MS, + updatedAt: nowMs, + }) + .where(($) => [$.id.eq(row.id), claimable($, nowMs)]) + .returning("id"), + ), + ).pipe(Effect.map((rows) => rows.length > 0)) + + /** Only the lease this worker took: a row reclaimed after it expired is not overwritten. */ + const settle = ( + row: AlertMemberNotificationRow, + claimedAt: number, + set: Partial>, + ) => + Effect.gen(function* () { + const nowMs = yield* runtime.now + yield* dbExecute((db) => + db.run( + PG.update(AlertMemberNotifications) + .set({ ...set, claimedAt: null, claimExpiresAt: null, updatedAt: nowMs }) + .where(($) => [ + $.id.eq(row.id), + $.status.eq("sending"), + $.claimedAt.eq(claimedAt), + ]), + ), + ) + }) + + const sendEmail = ( + row: AlertMemberNotificationRow, + input: ChatAlertCardInput, + members: Exit.Exit, { readonly _tag: string }>, + ) => + Effect.gen(function* () { + if (Exit.isFailure(members)) { + const notConfigured = Exit.findErrorOption(members).pipe( + Option.exists( + (error) => + error._tag === + "@maple/http/errors/AlertMemberDirectoryNotConfiguredError", + ), + ) + return notConfigured + ? failure("Member emails cannot be looked up on this deployment", false) + : failure("The member directory could not be read", true) + } + const member = Arr.findFirst( + members.value, + (candidate) => candidate.userId === row.userId, + ) + if (Option.isNone(member)) { + return failure("No email address is known for this member", false) + } + const content = yield* buildAlertEmailContent(input.context, input.linkUrl) + return yield* email.send(member.value.email, content.subject, content.html).pipe( + Effect.as({ status: "sent" }), + Effect.catch((error) => + Effect.succeed(failure(`Email failed: ${error.message}`, true)), + ), + ) + }).pipe(Effect.catch((error) => Effect.succeed(failure(error.message, false)))) + + const sendDirectMessage = ( + row: AlertMemberNotificationRow, + payload: MemberPayload, + input: ChatAlertCardInput, + ) => { + const { workspaceId, externalUserId } = payload.recipient + if (workspaceId === undefined || externalUserId === undefined) { + return Effect.succeed(failure("The page names no chat account", false)) + } + return chat + .postDirect({ + orgId: row.orgId, + workspaceId, + externalUserId, + blocks: buildChatAlertBlocks(input), + }) + .pipe( + Effect.as({ status: "sent" }), + Effect.catch((error: AlertDeliveryFailure) => + Effect.succeed(failure(error.message, error.error.retryable)), + ), + ) + } + + const deliverOne = Effect.fn("AlertMemberNotifier.deliver")(function* ( + row: AlertMemberNotificationRow, + incident: { readonly status: string; readonly acknowledgedAt: number | null } | undefined, + members: (orgId: OrgId) => Exit.Exit, { readonly _tag: string }>, + ) { + yield* Effect.annotateCurrentSpan({ + "maple.org_id": row.orgId, + "maple.alert.incident_id": row.incidentId, + "maple.escalation.run_id": row.runId, + "maple.escalation.recipient_channel": row.channel, + "maple.escalation.attempt": row.attemptNumber, + }) + const claimedAt = yield* runtime.now + if (!(yield* claim(row, claimedAt))) return "lost" as const + const annotateStatus = (status: string) => + Effect.annotateCurrentSpan("maple.escalation.recipient_status", status) + // Same rule as a queued step to a destination: a resolve or ack that won the race wins. + if ( + incident === undefined || + incident.status !== "open" || + incident.acknowledgedAt !== null + ) { + yield* settle(row, claimedAt, { + status: "skipped", + errorMessage: + incident !== undefined && incident.status === "open" + ? "Not sent: the incident was acknowledged before this page went out" + : "Not sent: the incident resolved before this page went out", + }) + yield* annotateStatus("skipped") + return "skipped" as const + } + const payload = decodePayload(row.payloadJson) + const input = Option.map(payload, (value) => + cardInput(row, value, env.MAPLE_APP_BASE_URL, claimedAt), + ) + const attempt: Attempt = + Option.isNone(payload) || Option.isNone(input) + ? failure("The stored page could not be read", false) + : row.channel === "email" + ? yield* sendEmail(row, input.value, members(row.orgId)) + : yield* sendDirectMessage(row, payload.value, input.value) + const attemptedAt = yield* runtime.now + if (attempt.status === "sent") { + yield* settle(row, claimedAt, { status: "sent", attemptedAt, errorMessage: null }) + yield* annotateStatus("sent") + return "sent" as const + } + const retry = canRetryAlertDelivery(row.attemptNumber, attempt.retryable) + const jitter = yield* Random.nextIntBetween(0, 1_000) + yield* settle( + row, + claimedAt, + retry + ? { + status: "queued", + attemptNumber: row.attemptNumber + 1, + scheduledAt: + attemptedAt + alertDeliveryRetryDelayMs(row.attemptNumber, jitter), + attemptedAt, + errorMessage: attempt.message, + } + : { status: "failed", attemptedAt, errorMessage: attempt.message }, + ) + yield* annotateStatus(retry ? "retrying" : "failed") + // The message can carry what the platform said, never the address it was sent to. + yield* Effect.logWarning("Escalation page to a member failed").pipe( + Effect.annotateLogs({ + notificationId: row.id, + incidentId: row.incidentId, + channel: row.channel, + attemptNumber: row.attemptNumber, + willRetry: retry, + }), + ) + return "failed" as const + }) + + const drain = Effect.gen(function* () { + const nowMs = yield* runtime.now + // Across orgs: the scheduler is the system actor. + const rows = yield* dbExecute((db) => + db.run( + PG.from(AlertMemberNotifications) + .select() + .where(($) => [claimable($, nowMs)]) + .orderBy(["scheduledAt", "asc"]) + .limit(DRAIN_LIMIT), + ), + ) + yield* Effect.annotateCurrentSpan("maple.escalation.member_notifications", rows.length) + if (rows.length === 0) return { processedCount: 0, failureCount: 0 } + const incidentIds = Arr.dedupe(Arr.map(rows, (row) => row.incidentId)) + const incidents = yield* dbExecute((db) => + db.run( + PG.from(AlertIncidents) + .select("id", "orgId", "status", "acknowledgedAt") + .where(($) => [$.id.in_(...incidentIds)]), + ), + ) + const incidentOf = (row: AlertMemberNotificationRow) => + Arr.findFirst( + incidents, + (incident) => incident.id === row.incidentId && incident.orgId === row.orgId, + ) + // One directory read per org with an email to send. + const emailOrgs = Arr.dedupe( + Arr.flatMap(rows, (row) => (row.channel === "email" ? [row.orgId] : [])), + ) + const directoryEntries = yield* Effect.forEach( + emailOrgs, + (orgId) => + Effect.map( + Effect.exit(orgMembers.listMembers(orgId)), + (exit) => [orgId, exit] as const, + ), + { concurrency: CONCURRENCY }, + ) + const directories = new Map(directoryEntries) + const members = (orgId: OrgId) => directories.get(orgId) ?? Exit.succeed([]) + const outcomes = yield* Effect.forEach( + rows, + (row) => + deliverOne(row, Option.getOrUndefined(incidentOf(row)), members).pipe( + Effect.catchCause((cause) => + Effect.logError("Escalation page crashed").pipe( + Effect.annotateLogs({ + notificationId: row.id, + cause: summarizeCause(cause), + }), + Effect.as("failed" as const), + ), + ), + ), + { concurrency: CONCURRENCY }, + ) + const processedCount = Arr.filter(outcomes, (outcome) => outcome !== "lost").length + const failureCount = Arr.filter(outcomes, (outcome) => outcome === "failed").length + yield* Effect.annotateCurrentSpan({ + "maple.escalation.member_notifications_processed": processedCount, + "maple.escalation.member_notifications_failed": failureCount, + }) + return { processedCount, failureCount } + }).pipe(Effect.withSpan("AlertMemberNotifier.drain")) + + return { drain } satisfies AlertMemberNotifierApi + }), + }, +) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(Layer.mergeAll(OrgMembersService.layer, EmailService.layer, ChatAlertPoster.layer)), + ) +} diff --git a/packages/backend/src/services/alerts/AlertsService.delivery.test.ts b/packages/backend/src/services/alerts/AlertsService.delivery.test.ts index dd70c20e1d..526d793341 100644 --- a/packages/backend/src/services/alerts/AlertsService.delivery.test.ts +++ b/packages/backend/src/services/alerts/AlertsService.delivery.test.ts @@ -470,6 +470,7 @@ describe("AlertsService chat digests", () => { : new AlertDeliveryError(fields), ) }), + postDirect: () => Effect.die("no person is paged here"), findChannel: () => Effect.succeed({ connector: Schema.decodeSync(ChatConnectorId)("testchat"), diff --git a/packages/backend/src/services/alerts/AlertsService.escalation.test.ts b/packages/backend/src/services/alerts/AlertsService.escalation.test.ts index c1d2d28314..e62b5e18c1 100644 --- a/packages/backend/src/services/alerts/AlertsService.escalation.test.ts +++ b/packages/backend/src/services/alerts/AlertsService.escalation.test.ts @@ -27,8 +27,10 @@ import { getError, makeLayer, makeWarehouseStub, + stubOrgMembersService, tracesRow, } from "./alerts-service-test-support" +import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" const decodeChainId = Schema.decodeUnknownSync(EscalationChainId) const trackedDbs: TestDb[] = [] @@ -65,7 +67,10 @@ const layerFor = ( ) => Layer.mergeAll( makeLayer(testDb, makeWarehouseStub(state), { fetch: recordingFetch(requests) }), - EscalationChainsService.layer.pipe(Layer.provide(testDb.layer)), + Layer.effect(EscalationChainsService, EscalationChainsService.make).pipe( + Layer.provide(testDb.layer), + Layer.provide(Layer.succeed(OrgMembersService, stubOrgMembersService())), + ), testDb.layer, ) diff --git a/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts b/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts new file mode 100644 index 0000000000..9251b25618 --- /dev/null +++ b/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts @@ -0,0 +1,587 @@ +import { + chatConnectorId, + ChatOutboundError, + socketIngress, + type ChatBlock, + type ChatTarget, +} from "@maple/chat-platform" +import { + AlertRuleUpsertRequest, + AlertValidationError, + EscalationChainCreateRequest, + OnCallOverrideCreateRequest, + OnCallScheduleCreateRequest, + OnCallScheduleInUseError, + WebhookAlertDestinationConfig, + type EscalationStep, + type OrgId, +} from "@maple/domain/http" +import { afterEach, assert, describe, it } from "@effect/vitest" +import { Duration, Effect, Layer, Ref, Schema, Array as Arr } from "effect" +import { TestClock } from "effect/testing" +import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@maple/backend/platform/test-pglite" +import { + ChatConnectorRegistry, + type RegisteredChatConnector, +} from "@maple/backend/services/integrations/chat-outbound" +import { OrgMembersService, type OrgMember } from "@maple/backend/services/org/OrgMembersService" +import { AlertsService, type AlertsServiceApi } from "./AlertsService" +import { AlertIncidentAckService } from "./AlertIncidentAckService" +import { ChatAlertPoster } from "./ChatAlertPoster" +import { EscalationChainsService } from "./EscalationChainsService" +import { OnCallSchedulesService } from "./OnCallSchedulesService" +import { + adminRoles, + asOrgId, + asUserId, + getError, + makeLayer, + makeWarehouseStub, + okFetch, + stubEmailService, + stubOrgMembersService, + tracesRow, +} from "./alerts-service-test-support" + +const trackedDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(trackedDbs)) + +const ana = asUserId("user_ana") +const ben = asUserId("user_ben") +const chen = asUserId("user_chen") +const MEMBERS: ReadonlyArray = [ + { userId: ana, email: "ana@acme.test", name: "Ana Alves", imageUrl: null }, + { userId: ben, email: "ben@acme.test", name: "Ben Brook", imageUrl: null }, + { userId: chen, email: "chen@acme.test", name: null, imageUrl: null }, +] +const SLACK = chatConnectorId("slack") +const WORKSPACE_ID = "44444444-4444-4444-8444-444444444444" +const at = (iso: string) => Date.parse(iso) +/** Tuesday of Ana's week: the weekly rotation hands off Monday 09:00 Berlin. */ +const NOW = at("2026-11-10T10:00:00Z") + +type PostMode = "ok" | "permanent" | "transient" + +interface FakeChat { + readonly mode: Ref.Ref + readonly opened: Array<{ readonly workspaceId: string; readonly userId: string }> + readonly posted: Array<{ readonly target: ChatTarget; readonly blocks: ReadonlyArray }> + readonly attempts: Array +} + +const unreachable = () => Effect.die("the page reached another transport method") + +/** A Slack stand-in: a DM is the user's own channel, and each post answers as `mode` says. */ +const fakeSlack = (chat: FakeChat): RegisteredChatConnector => ({ + id: SLACK, + manifest: { + id: SLACK, + name: "Slack", + description: "A connector that exists only in this test.", + icon: { viewBox: "0 0 24 24", paths: [] }, + accent: "#000000", + settingsFields: [], + }, + install: { + requiredConfig: [], + authorizeUrl: unreachable, + complete: unreachable, + decodeSettings: unreachable, + }, + outbound: { + connectorId: SLACK, + limits: { maxMessageChars: 1000, minEditInterval: Duration.millis(500) }, + requiredConfig: [], + transport: Effect.succeed({ + post: (target, blocks) => + Effect.flatMap(Ref.get(chat.mode), (mode) => { + chat.attempts.push(mode) + if (mode === "ok") { + chat.posted.push({ target, blocks }) + return Effect.succeed({ target, messageId: "ts-1" }) + } + return Effect.fail( + new ChatOutboundError({ + message: + mode === "permanent" + ? "Slack refused the call: messages_tab_disabled" + : "Slack answered 503", + connectorId: SLACK, + operation: "post", + ...(mode === "permanent" ? { reason: "not_found" as const } : undefined), + }), + ) + }), + directMessage: (workspaceId, userId) => + Effect.sync(() => { + chat.opened.push({ workspaceId, userId }) + return { workspaceId, channelId: userId } + }), + edit: unreachable, + whisper: unreachable, + typing: unreachable, + openThread: unreachable, + conversation: unreachable, + history: unreachable, + destinations: unreachable, + }), + }, + ingress: socketIngress({ + requiredConfig: [], + stateSchema: Schema.String, + initialState: "", + connectUrl: () => "wss://chat.test/gateway", + onOpen: (state) => ({ state }), + onFrame: (state) => ({ state }), + onClose: (state) => ({ state }), + heartbeat: (state) => ({ state }), + }), +}) + +const errorRate = (value: number): ReadonlyArray> => [ + tracesRow({ + count: 200, + avgDuration: 40, + p50Duration: 20, + p95Duration: 120, + p99Duration: 240, + errorRate: value, + satisfiedCount: 180, + toleratingCount: 10, + apdexScore: 0.925, + }), +] + +const layerFor = ( + testDb: TestDb, + state: { tracesAggregateRows: ReadonlyArray> }, + emails: Array<{ to: string; subject: string; html: string }>, + chat: FakeChat, +) => { + const members = Layer.succeed(OrgMembersService, stubOrgMembersService(MEMBERS)) + return Layer.mergeAll( + makeLayer( + testDb, + makeWarehouseStub(state), + { fetch: okFetch }, + stubEmailService(emails), + ChatAlertPoster.layer.pipe( + Layer.provide(Layer.succeed(ChatConnectorRegistry, [fakeSlack(chat)])), + ), + MEMBERS, + ), + Layer.effect(EscalationChainsService, EscalationChainsService.make).pipe( + Layer.provide(testDb.layer), + Layer.provide(members), + ), + Layer.effect(OnCallSchedulesService, OnCallSchedulesService.make).pipe( + Layer.provide(testDb.layer), + Layer.provide(members), + ), + Layer.effect(AlertIncidentAckService, AlertIncidentAckService.make).pipe( + Layer.provide(testDb.layer), + Layer.provide(members), + ), + ) +} + +const makeChat = Effect.map(Ref.make("ok"), (mode): FakeChat => ({ + mode, + opened: [], + posted: [], + attempts: [], +})) + +const tick = (alerts: AlertsServiceApi, minutes = 1) => + Effect.gen(function* () { + yield* TestClock.adjust(Duration.minutes(minutes)) + yield* alerts.runSchedulerTick() + }) + +const ticks = (alerts: AlertsServiceApi, minutes: number) => + Effect.forEach(Arr.range(1, minutes), () => tick(alerts), { discard: true }) + +interface PageRow { + readonly user_id: string + readonly channel: string + readonly status: string + readonly attempt_number: number + readonly error_message: string | null +} + +const pageRows = (testDb: TestDb, orgId: OrgId) => + Effect.promise(() => + testDb.pglite.query( + `select user_id, channel, status, attempt_number, error_message + from alert_member_notifications where org_id = $1 order by channel, created_at`, + [orgId], + ), + ).pipe(Effect.map((result) => result.rows)) + +const runStatus = (testDb: TestDb, orgId: OrgId) => + Effect.promise(() => + testDb.pglite.query<{ status: string; steps_sent: number }>( + "select status, steps_sent from alert_escalation_runs where org_id = $1", + [orgId], + ), + ).pipe(Effect.map((result) => result.rows[0])) + +/** "API primary": weekly Ana, Ben, Chen from Monday 2026-11-09 09:00 Berlin; Ben covers today. */ +const apiPrimary = (orgId: OrgId, options: { readonly gap?: boolean; readonly override?: boolean } = {}) => + Effect.gen(function* () { + const schedules = yield* OnCallSchedulesService + const schedule = yield* schedules.create( + orgId, + ana, + adminRoles, + new OnCallScheduleCreateRequest({ + name: "API primary", + timeZone: "Europe/Berlin", + layers: [ + { + name: "Primary", + participants: [ana, ben, chen], + rotation: { kind: "weekly", handoffWeekday: "mon", handoffTime: "09:00" }, + startsAt: at("2026-11-09T08:00:00Z"), + // Saturdays only: nobody is on call on a Tuesday. + ...(options.gap === true + ? { restrictions: [{ weekdays: ["sat"], startTime: "09:00", endTime: "17:00" }] } + : undefined), + }, + ], + }), + ) + if (options.override !== false) { + yield* schedules.createOverride( + orgId, + ana, + adminRoles, + schedule.id, + new OnCallOverrideCreateRequest({ + userId: ben, + startsAt: at("2026-11-10T08:00:00Z"), + endsAt: at("2026-11-11T08:00:00Z"), + }), + ) + } + return schedule + }) + +/** Ben linked his Slack account in a workspace the org connected. */ +const linkBenOnSlack = (testDb: TestDb, orgId: OrgId) => + Effect.promise(async () => { + await executeSql( + testDb, + `insert into chat_workspaces (id, org_id, connector, external_workspace_id, name, settings, created_at) + values ($1, $2, 'slack', 'T_ACME', 'Acme', '{}'::jsonb, now())`, + [WORKSPACE_ID, orgId], + ) + await executeSql( + testDb, + `insert into chat_identities (id, org_id, connector, external_user_id, user_id, display_name, created_at) + values (gen_random_uuid()::text, $1, 'slack', 'U_BEN', $2, 'ben', now())`, + [orgId, ben], + ) + }) + +/** Step 1 notifies a webhook, waits 10 minutes; step 2 pages the "API primary" on-call. */ +const setup = ( + orgName: string, + testDb: TestDb, + options: { readonly gap?: boolean; readonly override?: boolean } = {}, +) => + Effect.gen(function* () { + const alerts = yield* AlertsService + const chains = yield* EscalationChainsService + const orgId = asOrgId(orgName) + const team = yield* alerts.createDestination( + orgId, + ana, + adminRoles, + new WebhookAlertDestinationConfig({ + type: "webhook", + name: "team", + enabled: true, + url: "https://example.com/team", + signingSecret: "team-secret", + }), + ) + const schedule = yield* apiPrimary(orgId, options) + yield* linkBenOnSlack(testDb, orgId) + const steps: [EscalationStep, ...Array] = [ + { targets: [{ type: "destination", destinationId: team.id }], waitMinutes: 10 }, + { targets: [{ type: "schedule", scheduleId: schedule.id }], waitMinutes: 15 }, + ] + const chain = yield* chains.create( + orgId, + ana, + adminRoles, + new EscalationChainCreateRequest({ name: "API escalation", steps }), + ) + yield* alerts.createRule( + orgId, + ana, + adminRoles, + new AlertRuleUpsertRequest({ + name: "Checkout error rate", + enabled: true, + serviceNames: ["checkout"], + signalType: "error_rate", + comparator: "gt", + thresholds: [ + { + severity: "critical", + value: 5, + upper: null, + recoveryValue: null, + destinationIds: [team.id], + escalationChainId: chain.id, + }, + ], + windowMinutes: 5, + minimumSampleCount: 10, + consecutiveBreachesRequired: 2, + consecutiveHealthyRequired: 2, + renotifyIntervalMinutes: 60, + }), + ) + // Two breaching ticks open the incident and send step 1. + yield* alerts.runSchedulerTick() + yield* tick(alerts) + return { alerts, chains, orgId, schedule, chain } + }) + +describe("escalation pages the on-call", () => { + it.effect("pages whoever the schedule has on call at step time, by email and Slack DM", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRate(10) } + const emails: Array<{ to: string; subject: string; html: string }> = [] + return Effect.gen(function* () { + const chat = yield* makeChat + yield* TestClock.setTime(NOW) + return yield* Effect.gen(function* () { + const { alerts, orgId } = yield* setup("org_page_ben", testDb) + assert.deepStrictEqual(yield* pageRows(testDb, orgId), []) + + yield* ticks(alerts, 10) + // Ben covers Ana's week today, so step 2 pages him and only him. + assert.deepStrictEqual( + Arr.map(emails, (email) => email.to), + ["ben@acme.test"], + ) + assert.include(emails[0]?.subject ?? "", "Checkout error rate") + assert.deepStrictEqual(chat.opened, [{ workspaceId: "T_ACME", userId: "U_BEN" }]) + assert.deepStrictEqual( + Arr.map(chat.posted, (post) => post.target.channelId), + ["U_BEN"], + ) + assert.deepStrictEqual( + Arr.map(yield* pageRows(testDb, orgId), (row) => [row.user_id, row.channel, row.status]), + [ + [ben, "email", "sent"], + [ben, "slack", "sent"], + ], + ) + + // The timeline names him and the channel, never the address. + const [incident] = (yield* alerts.listIncidents(orgId, {})).incidents + if (incident === undefined) return assert.fail("expected an incident") + const events = yield* (yield* AlertIncidentAckService).listEvents(orgId, incident.id) + // Both pages went out in the same step, so their order is not fixed. + assert.sameDeepMembers( + Arr.map(events, (event) => [ + event.type, + event.page?.name, + event.page?.channel, + event.page?.status, + ]), + [ + ["paged", "Ben Brook", "email", "sent"], + ["paged", "Ben Brook", "slack", "sent"], + ], + ) + assert.notInclude(JSON.stringify(events), "ben@acme.test") + }).pipe(Effect.provide(layerFor(testDb, state, emails, chat))) + }) + }) + + it.effect("pages nobody for a schedule with no one on call, and still moves to the next step", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRate(10) } + const emails: Array<{ to: string; subject: string; html: string }> = [] + return Effect.gen(function* () { + const chat = yield* makeChat + yield* TestClock.setTime(NOW) + return yield* Effect.gen(function* () { + const { alerts, orgId } = yield* setup("org_page_gap", testDb, { gap: true, override: false }) + yield* ticks(alerts, 10) + assert.deepStrictEqual(yield* pageRows(testDb, orgId), []) + assert.lengthOf(emails, 0) + assert.lengthOf(chat.posted, 0) + assert.deepStrictEqual(yield* runStatus(testDb, orgId), { + status: "exhausted", + steps_sent: 2, + }) + }).pipe(Effect.provide(layerFor(testDb, state, emails, chat))) + }) + }) + + it.effect("skips a queued page once the incident is acknowledged", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRate(10) } + const emails: Array<{ to: string; subject: string; html: string }> = [] + return Effect.gen(function* () { + const chat = yield* makeChat + yield* TestClock.setTime(NOW) + return yield* Effect.gen(function* () { + const { alerts, orgId } = yield* setup("org_page_ack", testDb) + yield* ticks(alerts, 10) + // Pages queued by a step that committed just before the ack, as the drain finds them. + yield* Effect.promise(() => + executeSql( + testDb, + `insert into alert_member_notifications (id, org_id, incident_id, run_id, user_id, channel, + status, attempt_number, scheduled_at, payload_json, dedupe_key, created_at, updated_at) + select gen_random_uuid()::text, org_id, incident_id, run_id, $2, channel, 'queued', 1, + scheduled_at, payload_json, dedupe_key || ':late', created_at, updated_at + from alert_member_notifications where org_id = $1`, + [orgId, chen], + ), + ) + yield* Effect.promise(() => + executeSql( + testDb, + `update alert_incidents set acknowledged_at = now(), acknowledged_by_name = 'Ana', + acknowledged_via = 'web' where org_id = $1`, + [orgId], + ), + ) + yield* tick(alerts) + const late = Arr.filter(yield* pageRows(testDb, orgId), (row) => row.user_id === chen) + assert.deepStrictEqual( + Arr.map(late, (row) => row.status), + ["skipped", "skipped"], + ) + assert.include(late[0]?.error_message ?? "", "acknowledged") + assert.notInclude( + Arr.map(emails, (email) => email.to), + "chen@acme.test", + ) + }).pipe(Effect.provide(layerFor(testDb, state, emails, chat))) + }) + }) + + it.effect("does not retry a DM the platform refuses for good", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRate(10) } + const emails: Array<{ to: string; subject: string; html: string }> = [] + return Effect.gen(function* () { + const chat = yield* makeChat + yield* Ref.set(chat.mode, "permanent") + yield* TestClock.setTime(NOW) + return yield* Effect.gen(function* () { + const { alerts, orgId } = yield* setup("org_page_permanent", testDb) + yield* ticks(alerts, 20) + const slack = Arr.filter(yield* pageRows(testDb, orgId), (row) => row.channel === "slack") + assert.deepStrictEqual( + Arr.map(slack, (row) => [row.status, row.attempt_number]), + [["failed", 1]], + ) + assert.include(slack[0]?.error_message ?? "", "messages_tab_disabled") + assert.deepStrictEqual(chat.attempts, ["permanent"]) + }).pipe(Effect.provide(layerFor(testDb, state, emails, chat))) + }) + }) + + it.effect("retries a DM that failed for a passing reason, and sends it", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRate(10) } + const emails: Array<{ to: string; subject: string; html: string }> = [] + return Effect.gen(function* () { + const chat = yield* makeChat + yield* Ref.set(chat.mode, "transient") + yield* TestClock.setTime(NOW) + return yield* Effect.gen(function* () { + const { alerts, orgId } = yield* setup("org_page_transient", testDb) + yield* ticks(alerts, 10) + const queued = Arr.filter(yield* pageRows(testDb, orgId), (row) => row.channel === "slack") + assert.deepStrictEqual( + Arr.map(queued, (row) => [row.status, row.attempt_number]), + [["queued", 2]], + ) + yield* Ref.set(chat.mode, "ok") + yield* ticks(alerts, 2) + const sent = Arr.filter(yield* pageRows(testDb, orgId), (row) => row.channel === "slack") + assert.deepStrictEqual( + Arr.map(sent, (row) => [row.status, row.attempt_number]), + [["sent", 2]], + ) + assert.deepStrictEqual(chat.attempts, ["transient", "ok"]) + }).pipe(Effect.provide(layerFor(testDb, state, emails, chat))) + }) + }) +}) + +describe("escalation chain targets", () => { + it.effect("refuses an unknown user or schedule, and a schedule a chain pages cannot be deleted", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRate(1) } + const emails: Array<{ to: string; subject: string; html: string }> = [] + return Effect.gen(function* () { + const chat = yield* makeChat + yield* TestClock.setTime(NOW) + return yield* Effect.gen(function* () { + const chains = yield* EscalationChainsService + const schedules = yield* OnCallSchedulesService + const orgId = asOrgId("org_page_guards") + const schedule = yield* apiPrimary(orgId, { override: false }) + const create = (steps: [EscalationStep, ...Array]) => + chains.create( + orgId, + ana, + adminRoles, + new EscalationChainCreateRequest({ name: "Guarded", steps }), + ) + + const stranger = getError( + yield* Effect.exit( + create([ + { targets: [{ type: "user", userId: asUserId("user_zed") }], waitMinutes: 5 }, + ]), + ), + ) + if (!Schema.is(AlertValidationError)(stranger)) + return assert.fail("expected a validation error") + assert.include(stranger.message, "user_zed") + + const otherOrgs = yield* apiPrimary(asOrgId("org_page_guards_other"), { override: false }) + const foreign = getError( + yield* Effect.exit( + create([ + { targets: [{ type: "schedule", scheduleId: otherOrgs.id }], waitMinutes: 5 }, + ]), + ), + ) + assert.instanceOf(foreign, AlertValidationError) + + const chain = yield* create([ + { + targets: [ + { type: "schedule", scheduleId: schedule.id }, + { type: "user", userId: chen }, + ], + waitMinutes: 5, + }, + ]) + assert.deepStrictEqual((yield* schedules.get(orgId, schedule.id)).chainIds, [chain.id]) + const inUse = getError(yield* Effect.exit(schedules.delete(orgId, adminRoles, schedule.id))) + if (!Schema.is(OnCallScheduleInUseError)(inUse)) return assert.fail("expected in use") + assert.deepStrictEqual(inUse.chainNames, ["Guarded"]) + + yield* chains.delete(orgId, adminRoles, chain.id) + yield* schedules.delete(orgId, adminRoles, schedule.id) + assert.lengthOf((yield* schedules.list(orgId)).schedules, 0) + }).pipe(Effect.provide(layerFor(testDb, state, emails, chat))) + }) + }) +}) diff --git a/packages/backend/src/services/alerts/AlertsService.rules.test.ts b/packages/backend/src/services/alerts/AlertsService.rules.test.ts index 40049878b8..ba8b6d0b61 100644 --- a/packages/backend/src/services/alerts/AlertsService.rules.test.ts +++ b/packages/backend/src/services/alerts/AlertsService.rules.test.ts @@ -471,6 +471,7 @@ describe("AlertsService", () => { const lookups: Array = [] const poster = Layer.succeed(ChatAlertPoster, { post: () => Effect.die("no alert is posted here"), + postDirect: () => Effect.die("no person is paged here"), findChannel: (_orgId, _workspaceId, channelId) => Effect.suspend(() => { lookups.push(channelId) diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index fdb8639156..9ba432e0c5 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -147,6 +147,8 @@ import { escalationChainFor, ruleAtSeverity } from "./alert-thresholds" import { applyEscalationPlan, enabledDestinationIds, + loadStepRecipients, + stepDestinationIds, type EscalationWorkRow, endEscalationRunsAbove, loadEscalationChain, @@ -202,6 +204,7 @@ import { } from "./AlertRuleModel" import { mapSignalUnit, resolveSignalDisplay } from "./alert-signal-display" import { summarizeCause } from "@maple/backend/platform/describe-cause" +import { AlertMemberNotifier } from "./AlertMemberNotifier" const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)) @@ -655,6 +658,7 @@ export class AlertsService extends Context.Service - enabledDestinationIds( - db, - row.orgId, - Arr.map(plan.step.targets, (target) => target.destinationId), - ), + enabledDestinationIds(db, row.orgId, stepDestinationIds(plan.step)), ) : new Set() + // Who is on call is read now, at step time, not when the chain was saved. + const paged = + plan.kind === "send" + ? yield* dbExecute((db) => loadStepRecipients(db, row.orgId, plan.step, nowMs)) + : null + if (paged !== null && paged.gapScheduleIds.length > 0) { + yield* Effect.annotateCurrentSpan("maple.escalation.oncall_gap", true) + yield* Effect.logWarning("Escalation step targets a schedule with nobody on call").pipe( + Effect.annotateLogs({ + runId: row.id, + incidentId: row.incidentId, + scheduleIds: Arr.join(paged.gapScheduleIds, ","), + }), + ) + } const outcome = yield* dbExecute((db) => db.transaction( - applyEscalationPlan(db, { row, plan, nowMs, enabled, makeDeliveryId: makeUuid }), + applyEscalationPlan(db, { + row, + plan, + nowMs, + enabled, + recipients: paged?.recipients ?? [], + makeDeliveryId: makeUuid, + }), ), ) yield* Effect.annotateCurrentSpan({ @@ -5553,6 +5575,7 @@ export class AlertsService extends Context.Service diff --git a/packages/backend/src/services/alerts/ChatAlertPoster.ts b/packages/backend/src/services/alerts/ChatAlertPoster.ts index e5d909e4a1..2e7512b674 100644 --- a/packages/backend/src/services/alerts/ChatAlertPoster.ts +++ b/packages/backend/src/services/alerts/ChatAlertPoster.ts @@ -1,4 +1,4 @@ -import type { ChatBlock, ChatOutboundError, ChatOutboundTransport } from "@maple/chat-platform" +import type { ChatBlock, ChatOutboundError, ChatOutboundTransport, ChatTarget } from "@maple/chat-platform" import { AlertDeliveryAuthError, AlertDeliveryError, @@ -25,7 +25,7 @@ import { import { makePersistenceError } from "./alert-persistence" import { AlertRuntime } from "./AlertRuntime" import { failureForStatus } from "./delivery/runTransport" -import type { EffectTransportDeps } from "./delivery/Transport" +import type { ChatAlertPosted, EffectTransportDeps } from "./delivery/Transport" /* * The `chat` destination's two reaches into a linked workspace: posting an alert, and confirming @@ -67,8 +67,19 @@ export interface ChatChannelChoice { readonly channelName: string } +/** A page to one person: a direct message to the chat account they linked. */ +export interface ChatDirectAlertPost { + readonly orgId: OrgId + /** The `chat_workspaces` row id the message goes out through. */ + readonly workspaceId: ChatWorkspaceId + /** The platform's own id for the person's account. */ + readonly externalUserId: string + readonly blocks: ReadonlyArray +} + export interface ChatAlertPosterApi { readonly post: EffectTransportDeps["postChatAlert"] + readonly postDirect: (post: ChatDirectAlertPost) => Effect.Effect /** * The channel, if the org's workspace lists it. What stops a destination naming a channel in * somebody else's workspace: a connector with one deployment-wide credential would post there. @@ -120,104 +131,138 @@ const make: Effect.Effect - Effect.fail(deliveryError("Failed to load the chat workspace", cause)), - // Maple could not open its own stored credential (for example after a - // key rotation): retryable, so no destination is disabled for it. - "@maple/backend/ChatWorkspaceCredentialsUnreadable": (cause) => - Effect.fail( - deliveryError( - "The chat workspace's stored credential is unreadable. Reinstall the app to relink it", - cause, - ), + /** Posts `blocks` through the org's workspace, to the target `targetOf` names on it. */ + const deliver = Effect.fnUntraced(function* ( + request: { + readonly orgId: OrgId + readonly workspaceId: ChatWorkspaceId + readonly blocks: ReadonlyArray + }, + targetOf: ( + transport: ChatOutboundTransport, + externalWorkspaceId: string, + ) => Effect.Effect, + ) { + yield* Effect.annotateCurrentSpan({ + orgId: request.orgId, + "maple.delivery.destination_type": "chat", + }) + if (Option.isNone(encryptionKey)) { + return yield* deliveryError("MAPLE_INGEST_KEY_ENCRYPTION_KEY is unusable on this deployment") + } + const workspace = yield* loadOwnedChatWorkspace( + database, + registry, + request.orgId, + request.workspaceId, + encryptionKey.value, + ).pipe( + Effect.catchTags({ + // A failed LOOKUP is not a missing workspace: keep it retryable. + "@maple/backend/lib/DatabaseError": (cause) => + Effect.fail(deliveryError("Failed to load the chat workspace", cause)), + // Maple could not open its own stored credential (for example after a + // key rotation): retryable, so no destination is disabled for it. + "@maple/backend/ChatWorkspaceCredentialsUnreadable": (cause) => + Effect.fail( + deliveryError( + "The chat workspace's stored credential is unreadable. Reinstall the app to relink it", + cause, ), - }), + ), + }), + ) + if (Option.isNone(workspace)) { + return yield* new AlertDeliveryTargetMissingError({ + message: + "The chat workspace was unlinked. Link it again, or point this destination at another channel", + destinationType: "chat", + }) + } + const { connector, externalWorkspaceId } = workspace.value + yield* Effect.annotateCurrentSpan({ "maple.chat.connector": connector.id }) + // A deployment gap, not the org's: retryable, so no destination is disabled for it. + const missing = missingOutboundConfig(workspace.value, outboundConfig) + if (missing.length > 0) { + return yield* deliveryError( + `${connector.manifest.name} is not configured on this deployment (${Arr.join(missing, ", ")})`, ) - if (Option.isNone(workspace)) { - return yield* new AlertDeliveryTargetMissingError({ - message: - "The chat workspace was unlinked — point this destination at another channel", - destinationType: "chat", - }) - } - const { connector, externalWorkspaceId } = workspace.value - yield* Effect.annotateCurrentSpan({ "maple.chat.connector": connector.id }) - // A deployment gap, not the org's: retryable, so no destination is disabled for it. - const missing = missingOutboundConfig(workspace.value, outboundConfig) - if (missing.length > 0) { - return yield* deliveryError( - `${connector.manifest.name} is not configured on this deployment (${Arr.join(missing, ", ")})`, - ) - } - const transportKey = `${connector.id}\u0000${externalWorkspaceId}\u0000${workspace.value.credentials ?? ""}` - const cached = (yield* Ref.get(transports)).get(transportKey) - const transport = - cached ?? (yield* chatOutboundTransport(workspace.value, outboundConfig, httpClient)) - if (cached === undefined) - yield* Ref.update(transports, (map) => new Map(map).set(transportKey, transport)) - const target = { workspaceId: externalWorkspaceId, channelId: request.channelId } - const imageless = withoutImages(request.blocks) - const posted = yield* transport.post(target, request.blocks).pipe( - Effect.catchIf( - (error) => error.reason === "rejected", - (error) => - imageless === undefined - ? Effect.fail(error) - : Effect.logWarning( - "Chat alert rejected with its chart image; posting it without", - ).pipe( - Effect.annotateLogs({ - "maple.chat.connector": connector.id, - error: error.message, - }), - Effect.andThen( - Effect.annotateCurrentSpan("maple.delivery.image_dropped", true), + } + const transportKey = `${connector.id}\u0000${externalWorkspaceId}\u0000${workspace.value.credentials ?? ""}` + const cached = (yield* Ref.get(transports)).get(transportKey) + const transport = + cached ?? (yield* chatOutboundTransport(workspace.value, outboundConfig, httpClient)) + if (cached === undefined) + yield* Ref.update(transports, (map) => new Map(map).set(transportKey, transport)) + const imageless = withoutImages(request.blocks) + const posted = yield* targetOf(transport, externalWorkspaceId).pipe( + Effect.flatMap((target) => + transport.post(target, request.blocks).pipe( + Effect.catchIf( + (error) => error.reason === "rejected", + (error) => + imageless === undefined + ? Effect.fail(error) + : Effect.logWarning( + "Chat alert rejected with its chart image; posting it without", + ).pipe( + Effect.annotateLogs({ + "maple.chat.connector": connector.id, + error: error.message, + }), + Effect.andThen( + Effect.annotateCurrentSpan( + "maple.delivery.image_dropped", + true, + ), + ), + Effect.andThen(transport.post(target, imageless)), ), - Effect.andThen(transport.post(target, imageless)), - ), + ), ), - Effect.mapError(chatDeliveryFailure), - Effect.timeoutOrElse({ - duration: Duration.millis(runtime.deliveryTimeoutMs()), - orElse: () => - Effect.fail( - deliveryError( - `${connector.manifest.name} delivery timed out after ${runtime.deliveryTimeoutMs()}ms`, - ), + ), + Effect.mapError(chatDeliveryFailure), + Effect.timeoutOrElse({ + duration: Duration.millis(runtime.deliveryTimeoutMs()), + orElse: () => + Effect.fail( + deliveryError( + `${connector.manifest.name} delivery timed out after ${runtime.deliveryTimeoutMs()}ms`, ), - }), - ) - return { connectorName: connector.manifest.name, messageId: posted.messageId } - }, - // Every failure, the ones before the post included: a flat rate of one tag is one - // broken destination, and the span is where that is read. - Effect.tapError((failure) => - Effect.annotateCurrentSpan({ - "maple.delivery.failure_tag": failure._tag, - "maple.delivery.retryable": failure.error.retryable, + ), }), - ), - ) + ) + return { + connectorName: connector.manifest.name, + messageId: posted.messageId, + } satisfies ChatAlertPosted + }) + + // Every failure, the ones before the post included: a flat rate of one tag is one + // broken destination, and the span is where that is read. + const tagFailure = (self: Effect.Effect) => + self.pipe( + Effect.tapError((failure) => + Effect.annotateCurrentSpan({ + "maple.delivery.failure_tag": failure._tag, + "maple.delivery.retryable": failure.error.retryable, + }), + ), + ) + + const post: ChatAlertPosterApi["post"] = Effect.fn("AlertDelivery.chat")(function* (request) { + return yield* deliver(request, (_transport, externalWorkspaceId) => + Effect.succeed({ workspaceId: externalWorkspaceId, channelId: request.channelId }), + ) + }, tagFailure) + + const postDirect: ChatAlertPosterApi["postDirect"] = Effect.fn("AlertDelivery.chatDirect")(function* ( + request, + ) { + return yield* deliver(request, (transport, externalWorkspaceId) => + transport.directMessage(externalWorkspaceId, request.externalUserId), + ) + }, tagFailure) const findChannel: ChatAlertPosterApi["findChannel"] = Effect.fn("ChatAlertPoster.findChannel")( function* (orgId, workspaceId, channelId) { @@ -274,7 +319,7 @@ const make: Effect.Effect Effect.Effect< EscalationChainDocument, - AlertForbiddenError | AlertValidationError | AlertPersistenceError + | AlertForbiddenError + | AlertValidationError + | AlertPersistenceError + | AlertMemberDirectoryUnavailableError > readonly update: ( orgId: OrgId, @@ -62,7 +73,11 @@ export interface EscalationChainsServiceApi { request: EscalationChainUpdateRequest, ) => Effect.Effect< EscalationChainDocument, - AlertForbiddenError | AlertValidationError | AlertPersistenceError | EscalationChainNotFoundError + | AlertForbiddenError + | AlertValidationError + | AlertPersistenceError + | AlertMemberDirectoryUnavailableError + | EscalationChainNotFoundError > /** Refused while a rule's tier names the chain; escalations already running finish from their snapshot. */ readonly delete: ( @@ -119,9 +134,6 @@ const ruleRefsByChain = (db: MapleDb, orgId: OrgId) => }), ) -const stepDestinationIds = (steps: EscalationSteps): ReadonlyArray => - Arr.dedupe(Arr.flatMap(steps, (step) => Arr.map(step.targets, (target) => target.destinationId))) - const toDocument = (row: EscalationChainRow, refs: ReadonlyArray) => new EscalationChainDocument({ id: row.id, @@ -141,6 +153,7 @@ export class EscalationChainsService extends Context.Service< make: Effect.gen(function* () { const database = yield* Database const runtime = yield* AlertRuntime + const orgMembers = yield* OrgMembersService const dbExecute = makeDbExecute(database, "EscalationChainsService", makePersistenceError) const requireManage = (roles: ReadonlyArray) => @@ -180,21 +193,57 @@ export class EscalationChainsService extends Context.Service< // The org's advisory lock: rule writes and destination deletion take it around their reference checks. const lockOrg = lockOrgAlertRules - const requireDestinations = (db: MapleDb, orgId: OrgId, steps: EscalationSteps) => + /** Under the org lock, so a destination or schedule cannot be deleted between check and write. */ + const requireTargets = (db: MapleDb, orgId: OrgId, steps: EscalationSteps) => Effect.gen(function* () { - const ids = stepDestinationIds(steps) - const rows = yield* db.run( - PG.from(AlertDestinations) - .select("id") - .where(($) => [$.orgId.eq(orgId), $.id.in_(...ids)]), - ) - if (rows.length < ids.length) { + const { destinationIds, scheduleIds } = escalationStepTargetIds(steps) + const [destinations, schedules] = yield* Effect.all([ + destinationIds.length === 0 + ? Effect.succeed([]) + : db.run( + PG.from(AlertDestinations) + .select("id") + .where(($) => [$.orgId.eq(orgId), $.id.in_(...destinationIds)]), + ), + scheduleIds.length === 0 + ? Effect.succeed([]) + : db.run( + PG.from(OnCallSchedules) + .select("id") + .where(($) => [$.orgId.eq(orgId), $.id.in_(...scheduleIds)]), + ), + ]) + if (destinations.length < destinationIds.length) { return yield* validationError( "An escalation step names a destination that does not exist", ) } + if (schedules.length < scheduleIds.length) { + return yield* validationError( + "An escalation step names an on-call schedule that does not exist", + ) + } }) + /** Paged users must be members; skipped where there is no member directory (self-hosted). */ + const requireMembers = (orgId: OrgId, steps: EscalationSteps) => { + const { userIds } = escalationStepTargetIds(steps) + return userIds.length === 0 + ? Effect.void + : orgMembers.resolveMembers(orgId, userIds).pipe( + Effect.asVoid, + Effect.catchTags({ + "@maple/http/errors/AlertMemberDirectoryNotConfiguredError": () => Effect.void, + "@maple/http/errors/AlertRecipientSelectionError": (error) => + Effect.fail( + validationError( + `An escalation step names users who are not members of this workspace: ${Arr.join(error.unknownUserIds, ", ")}`, + ), + ), + }), + ) + } + const list = Effect.fn("EscalationChainsService.list")(function* (orgId: OrgId) { const [rows, refs] = yield* dbExecute((db) => Effect.all([ @@ -230,6 +279,7 @@ export class EscalationChainsService extends Context.Service< request: EscalationChainCreateRequest, ) { yield* requireManage(roles) + yield* requireMembers(orgId, request.steps) const nowMs = yield* runtime.now const id = decodeChainIdSync(runtime.makeUuid()) const row = yield* dbExecute((db) => @@ -247,7 +297,7 @@ export class EscalationChainsService extends Context.Service< `Organizations may have at most ${MAX_CHAINS_PER_ORG} escalation chains`, ) } - yield* requireDestinations(db, orgId, request.steps) + yield* requireTargets(db, orgId, request.steps) const [inserted] = yield* db.run( PG.insertInto(EscalationChains) .values({ @@ -282,13 +332,14 @@ export class EscalationChainsService extends Context.Service< request: EscalationChainUpdateRequest, ) { yield* requireManage(roles) + if (request.steps !== undefined) yield* requireMembers(orgId, request.steps) const nowMs = yield* runtime.now const [row, refs] = yield* dbExecute((db) => db.transaction( Effect.gen(function* () { yield* lockOrg(db, orgId) yield* findRow(db, orgId, chainId) - if (request.steps !== undefined) yield* requireDestinations(db, orgId, request.steps) + if (request.steps !== undefined) yield* requireTargets(db, orgId, request.steps) const [updated] = yield* db.run( PG.update(EscalationChains) .set({ @@ -360,5 +411,5 @@ export class EscalationChainsService extends Context.Service< return { list, get, create, update, delete: remove } satisfies EscalationChainsServiceApi }), }) { - static readonly layer = Layer.effect(this, this.make) + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(OrgMembersService.layer)) } diff --git a/packages/backend/src/services/alerts/OnCallSchedulesService.ts b/packages/backend/src/services/alerts/OnCallSchedulesService.ts index 083aa1924e..054b8dcea9 100644 --- a/packages/backend/src/services/alerts/OnCallSchedulesService.ts +++ b/packages/backend/src/services/alerts/OnCallSchedulesService.ts @@ -14,12 +14,14 @@ import { OnCallOverridesListResponse, OnCallScheduleDocument, OnCallScheduleId as OnCallScheduleIdSchema, + OnCallScheduleInUseError, OnCallScheduleNotFoundError, OnCallSchedulesListResponse, OnCallShiftDocument, OnCallShiftsResponse, type AlertMemberDirectoryNotConfiguredError, type AlertMemberDirectoryUnavailableError, + type EscalationChainId, type OnCallLayers, type OnCallOverride, type OnCallOverrideCreateRequest, @@ -34,6 +36,7 @@ import { } from "@maple/domain/http" import * as PG from "@maple-dev/effect-orm/postgres" import { + EscalationChains, OnCallOverrides, OnCallSchedules, type OnCallOverrideRow, @@ -87,7 +90,7 @@ export interface OnCallSchedulesServiceApi { orgId: OrgId, roles: ReadonlyArray, scheduleId: OnCallScheduleId, - ) => Effect.Effect + ) => Effect.Effect /** Overrides that end after `fromMs` (default now), by start. */ readonly listOverrides: ( orgId: OrgId, @@ -128,6 +131,34 @@ const validationError = (message: string) => new AlertValidationError({ message, const decodeScheduleIdSync = Schema.decodeUnknownSync(OnCallScheduleIdSchema) const decodeOverrideIdSync = Schema.decodeUnknownSync(OnCallOverrideIdSchema) const decodeIsoSync = Schema.decodeUnknownSync(IsoDateTimeString) +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)) + +/** Chains with a step that targets each schedule, from every chain's steps. */ +const chainIdsBySchedule = (db: MapleDb, orgId: OrgId) => + db + .run( + PG.from(EscalationChains) + .select("id", "stepsJson") + .where(($) => [$.orgId.eq(orgId)]), + ) + .pipe( + Effect.map((rows) => { + const bySchedule = new Map>() + Arr.forEach(rows, (row) => { + const scheduleIds = Arr.dedupe( + Arr.flatMap(row.stepsJson, (step) => + Arr.flatMap(step.targets, (target) => + target.type === "schedule" ? [target.scheduleId] : [], + ), + ), + ) + Arr.forEach(scheduleIds, (scheduleId) => { + bySchedule.set(scheduleId, [...(bySchedule.get(scheduleId) ?? []), row.id]) + }) + }) + return bySchedule + }), + ) const iso = (ms: number) => decodeIsoSync(DateTime.formatIso(DateTime.makeUnsafe(ms))) const configOf = (row: Pick): OnCallScheduleConfig => ({ @@ -162,7 +193,12 @@ const toOverrideDocument = (row: OnCallOverrideRow) => createdAt: iso(row.createdAt), }) -const toDocument = (row: OnCallScheduleRow, overrides: ReadonlyArray, nowMs: number) => { +const toDocument = ( + row: OnCallScheduleRow, + overrides: ReadonlyArray, + nowMs: number, + chainIds: ReadonlyArray, +) => { const now = resolveOnCallAt(configOf(row), Arr.map(overrides, toOverride), nowMs) return new OnCallScheduleDocument({ id: row.id, @@ -174,8 +210,7 @@ const toDocument = (row: OnCallScheduleRow, overrides: ReadonlyArray + const [rows, overrides, chains] = yield* dbExecute((db) => Effect.all([ db.run( PG.from(OnCallSchedules) @@ -281,6 +316,7 @@ export class OnCallSchedulesService extends Context.Service< .limit(MAX_ONCALL_SCHEDULES_PER_ORG), ), overlappingOverrides(db, orgId, from, to), + chainIdsBySchedule(db, orgId), ]), ) yield* Effect.annotateCurrentSpan("result.rowCount", rows.length) @@ -290,6 +326,7 @@ export class OnCallSchedulesService extends Context.Service< row, Arr.filter(overrides, (o) => o.scheduleId === row.id), nowMs, + chains.get(row.id) ?? [], ), ), }) @@ -300,7 +337,12 @@ export class OnCallSchedulesService extends Context.Service< return Effect.all([ findRow(db, orgId, scheduleId), overlappingOverrides(db, orgId, from, to, scheduleId), - ]).pipe(Effect.map(([row, overrides]) => toDocument(row, overrides, nowMs))) + chainIdsBySchedule(db, orgId), + ]).pipe( + Effect.map(([row, overrides, chains]) => + toDocument(row, overrides, nowMs, chains.get(row.id) ?? []), + ), + ) } const get = Effect.fn("OnCallSchedulesService.get")(function* ( @@ -361,7 +403,7 @@ export class OnCallSchedulesService extends Context.Service< if (row === undefined) { return yield* new AlertPersistenceError({ message: "On-call schedule was not created" }) } - return toDocument(row, [], nowMs) + return toDocument(row, [], nowMs, []) }) const update = Effect.fn("OnCallSchedulesService.update")(function* ( @@ -413,7 +455,31 @@ export class OnCallSchedulesService extends Context.Service< yield* dbExecute((db) => db.transaction( Effect.gen(function* () { + // Chain writes check their schedules under this lock, so none can start + // targeting this one between the check and the delete. + yield* lockOrgAlertRules(db, orgId) yield* findRow(db, orgId, scheduleId) + const chains = yield* db.run( + PG.from(EscalationChains) + .select("id", "name") + .where(($) => [ + $.orgId.eq(orgId), + PG.sql.cond`${$.stepsJson} @> ${encodeJson([ + { targets: [{ type: "schedule", scheduleId }] }, + ])}::jsonb`, + ]), + ) + if (chains.length > 0) { + return yield* new OnCallScheduleInUseError({ + message: `On-call schedule is still paged by escalation chains: ${Arr.join( + Arr.map(chains, (chain) => chain.name), + ", ", + )}`, + scheduleId, + chainIds: Arr.map(chains, (chain) => chain.id), + chainNames: Arr.map(chains, (chain) => chain.name), + }) + } // Overrides go with it through the foreign key's cascade. yield* db.run( PG.deleteFrom(OnCallSchedules).where(($) => [ diff --git a/packages/backend/src/services/alerts/alert-escalation-runs.ts b/packages/backend/src/services/alerts/alert-escalation-runs.ts index eeb6e25ff1..148bdefd08 100644 --- a/packages/backend/src/services/alerts/alert-escalation-runs.ts +++ b/packages/backend/src/services/alerts/alert-escalation-runs.ts @@ -4,26 +4,39 @@ * the delivery drain, so a due step is sent in the same tick. Each step is a compare-and-set on the * run's position committed with the step's delivery rows: at most one writer sends a given step. */ -import { makeAlertDeliveryKey, planEscalationAdvance, type EscalationAdvancePlan } from "@maple/alerting-core" +import { + ONCALL_LOOKAROUND_MS, + makeAlertDeliveryKey, + onCallAt, + planEscalationAdvance, + type EscalationAdvancePlan, +} from "@maple/alerting-core" import type { AlertDestinationId, AlertIncidentId, AlertSeverity, EscalationChainId, + EscalationStep, EscalationSteps, + OnCallScheduleId, OrgId, } from "@maple/domain/http" -import type { AlertEscalationRunId } from "@maple/domain/primitives" +import type { AlertEscalationRunId, ChatConnectorId, ChatWorkspaceId, UserId } from "@maple/domain/primitives" import * as PG from "@maple-dev/effect-orm/postgres" import { AlertDeliveryEvents, AlertDestinations, AlertEscalationRuns, AlertIncidents, + AlertMemberNotifications, + ChatIdentities, + ChatWorkspaces, EscalationChains, + OnCallOverrides, + OnCallSchedules, } from "@maple/db/tables" import type { MapleDb } from "@maple/db/client" -import { Array as Arr, DateTime, Effect } from "effect" +import { Array as Arr, DateTime, Effect, Option } from "effect" import { queuedDeliveryEventRow } from "./alert-delivery-rows" const LIVE_STATUSES = ["active", "acknowledged"] as const @@ -244,6 +257,116 @@ export const enabledDestinationIds = (db: MapleDb, orgId: OrgId, ids: ReadonlyAr ) .pipe(Effect.map((rows) => new Set(Arr.map(rows, (row) => row.id)))) +/** One channel a person is paged on: email, or a direct message through a linked chat account. */ +export type MemberRecipient = + | { readonly userId: UserId; readonly channel: "email" } + | { + readonly userId: UserId + readonly channel: ChatConnectorId + /** The `chat_workspaces` row the message goes out through. */ + readonly workspaceId: ChatWorkspaceId + readonly externalUserId: string + } + +/** A step's destination targets. */ +export const stepDestinationIds = (step: EscalationStep): ReadonlyArray => + Arr.dedupe( + Arr.flatMap(step.targets, (target) => (target.type === "destination" ? [target.destinationId] : [])), + ) + +export interface StepRecipients { + readonly recipients: ReadonlyArray + /** Users paged, deduped across targets. */ + readonly userIds: ReadonlyArray + /** Schedule targets with nobody on call at `nowMs` (or deleted since). */ + readonly gapScheduleIds: ReadonlyArray +} + +/** + * Who a step pages at `nowMs`: its user targets, plus whoever each schedule target has on call. + * Each person gets an email row and one row per chat account they linked in the org. + */ +export const loadStepRecipients = (db: MapleDb, orgId: OrgId, step: EscalationStep, nowMs: number) => + Effect.gen(function* () { + const directUserIds = Arr.flatMap(step.targets, (target) => + target.type === "user" ? [target.userId] : [], + ) + const scheduleIds = Arr.dedupe( + Arr.flatMap(step.targets, (target) => (target.type === "schedule" ? [target.scheduleId] : [])), + ) + const [schedules, overrides] = yield* Effect.all([ + scheduleIds.length === 0 + ? Effect.succeed([]) + : db.run( + PG.from(OnCallSchedules) + .select("id", "timeZone", "layersJson") + .where(($) => [$.orgId.eq(orgId), $.id.in_(...scheduleIds)]), + ), + scheduleIds.length === 0 + ? Effect.succeed([]) + : db.run( + PG.from(OnCallOverrides) + .select("id", "scheduleId", "userId", "startsAt", "endsAt", "createdAt") + .where(($) => [ + $.orgId.eq(orgId), + $.scheduleId.in_(...scheduleIds), + $.endsAt.gt(nowMs - ONCALL_LOOKAROUND_MS), + $.startsAt.lt(nowMs + ONCALL_LOOKAROUND_MS), + ]), + ), + ]) + const onCall = Arr.map(scheduleIds, (scheduleId) => { + const schedule = Arr.findFirst(schedules, (row) => row.id === scheduleId) + if (Option.isNone(schedule)) return { scheduleId, userId: null } + const shift = onCallAt( + { timeZone: schedule.value.timeZone, layers: schedule.value.layersJson }, + Arr.filter(overrides, (override) => override.scheduleId === scheduleId), + nowMs, + ) + return { scheduleId, userId: shift === null ? null : shift.userId } + }) + const userIds = Arr.dedupe([ + ...directUserIds, + ...Arr.flatMap(onCall, (entry) => (entry.userId === null ? [] : [entry.userId])), + ]) + const gapScheduleIds = Arr.flatMap(onCall, (entry) => + entry.userId === null ? [entry.scheduleId] : [], + ) + if (userIds.length === 0) return { recipients: [], userIds, gapScheduleIds } satisfies StepRecipients + const [identities, workspaces] = yield* Effect.all([ + db.run( + PG.from(ChatIdentities) + .select("userId", "connector", "externalUserId") + .where(($) => [$.orgId.eq(orgId), $.userId.in_(...userIds)]), + ), + db.run( + PG.from(ChatWorkspaces) + .select("id", "connector", "createdAt") + .where(($) => [$.orgId.eq(orgId)]) + .orderBy(["createdAt", "asc"]), + ), + ]) + // A linked account is reached through the org's first workspace on its connector. + const chatRecipients = Arr.flatMap(identities, (identity): ReadonlyArray => { + const workspace = Arr.findFirst(workspaces, (row) => row.connector === identity.connector) + return Option.isNone(workspace) + ? [] + : [ + { + userId: identity.userId, + channel: identity.connector, + workspaceId: workspace.value.id, + externalUserId: identity.externalUserId, + }, + ] + }) + const recipients = Arr.flatMap(userIds, (userId): ReadonlyArray => [ + { userId, channel: "email" }, + ...Arr.filter(chatRecipients, (recipient) => recipient.userId === userId), + ]) + return { recipients, userIds, gapScheduleIds } satisfies StepRecipients + }) + export type EscalationApplyOutcome = "sent" | "ended" | "paused" | "resumed" | "deferred" | "lost" | "idle" type RunUpdate = Partial> @@ -268,6 +391,8 @@ export interface ApplyEscalationPlanInput { readonly nowMs: number /** Of the step's destinations, those enabled; only needed for a `send`. */ readonly enabled: ReadonlySet + /** The people the step pages, one entry per channel; only needed for a `send`. */ + readonly recipients?: ReadonlyArray readonly makeDeliveryId: () => string } @@ -333,15 +458,13 @@ export const applyEscalationPlan = (db: MapleDb, input: ApplyEscalationPlanInput passCount: row.repeatCount + 1, }, } - const destinationIds = Arr.dedupe( - Arr.filter( - Arr.map(plan.step.targets, (target) => target.destinationId), - (id) => input.enabled.has(id), - ), + const destinationIds = Arr.filter(stepDestinationIds(plan.step), (id) => + input.enabled.has(id), ) - if (destinationIds.length === 0) { + const recipients = input.recipients ?? [] + if (destinationIds.length === 0 && recipients.length === 0) { yield* Effect.logWarning( - "Escalation step has no enabled destination; nobody was notified", + "Escalation step has no enabled destination or person to page; nobody was notified", ).pipe( Effect.annotateLogs({ runId: row.id, @@ -380,6 +503,35 @@ export const applyEscalationPlan = (db: MapleDb, input: ApplyEscalationPlanInput ), { discard: true }, ) + // Committed with the position, so a lost compare-and-set queues no page either. + yield* Effect.forEach( + recipients, + (recipient) => + db.run( + PG.insertInto(AlertMemberNotifications) + .values({ + id: input.makeDeliveryId(), + orgId: row.orgId, + incidentId: row.incidentId, + runId: row.id, + userId: recipient.userId, + channel: recipient.channel, + status: "queued", + attemptNumber: 1, + scheduledAt: nowMs, + claimedAt: null, + claimExpiresAt: null, + payloadJson: { ...payload, recipient }, + dedupeKey: `${row.id}:${plan.passIndex + 1}:${plan.stepIndex + 1}:${recipient.userId}:${recipient.channel}`, + errorMessage: null, + attemptedAt: null, + createdAt: nowMs, + updatedAt: nowMs, + }) + .onConflictDoNothing(), + ), + { discard: true }, + ) return "sent" satisfies EscalationApplyOutcome } } 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 6102371ff1..eb5436d436 100644 --- a/packages/backend/src/services/alerts/alerts-service-test-support.ts +++ b/packages/backend/src/services/alerts/alerts-service-test-support.ts @@ -46,6 +46,7 @@ import { LinearIssueTracker } from "./LinearIssueTracker" import { EmailService } from "@maple/backend/platform/EmailService" import { OrgClickHouseSettingsService } from "@maple/backend/services/org/OrgClickHouseSettingsService" import { OrgMembersService, type OrgMember } from "@maple/backend/services/org/OrgMembersService" +import { AlertMemberNotifier } from "./AlertMemberNotifier" import { QueryEngineService } from "@maple/backend/services/warehouse/QueryEngineService" import { executeSql, type TestDb } from "@maple/backend/platform/test-pglite" import { Database } from "@maple/backend/platform/DatabaseLive" @@ -249,6 +250,7 @@ export const makeLayer = ( overrides: TestOverrides = {}, emailStub?: (typeof EmailService)["Service"], chatAlertPoster: Layer.Layer = ChatAlertPoster.layer, + members: ReadonlyArray = TEST_ORG_MEMBERS, ) => { const { fetch: fetchImpl = globalThis.fetch, chatSessions, ...runtimeOverrides } = overrides const configLive = makeConfig() @@ -278,7 +280,7 @@ export const makeLayer = ( Layer.provide(Layer.mergeAll(linearOAuthLive, databaseLive)), ) const emailLive = Layer.succeed(EmailService, emailStub ?? stubEmailService()) - const orgMembersLive = Layer.succeed(OrgMembersService, stubOrgMembersService()) + const orgMembersLive = Layer.succeed(OrgMembersService, stubOrgMembersService(members)) const orgChSettingsLive = OrgClickHouseSettingsService.layer.pipe( Layer.provide(Layer.mergeAll(envLive, databaseLive, edgeCacheLive)), ) @@ -304,7 +306,12 @@ export const makeLayer = ( Layer.provide(Layer.mergeAll(databaseLive, runtimeLive)), ) + const memberNotifierLive = Layer.effect(AlertMemberNotifier, AlertMemberNotifier.make).pipe( + Layer.provide(chatAlertPoster), + Layer.provide(Layer.mergeAll(envLive, databaseLive, runtimeLive, emailLive, orgMembersLive)), + ) const alertsLive = Layer.effect(AlertsService, AlertsService.make).pipe( + Layer.provide(memberNotifierLive), Layer.provide(FetchHttpClient.layer), Layer.provide(chatAlertPoster), Layer.provide( diff --git a/packages/backend/src/services/alerts/delivery/transports/chat.ts b/packages/backend/src/services/alerts/delivery/transports/chat.ts index a03a0a508f..57db188cae 100644 --- a/packages/backend/src/services/alerts/delivery/transports/chat.ts +++ b/packages/backend/src/services/alerts/delivery/transports/chat.ts @@ -10,6 +10,7 @@ import { alertAccentColor, truncate, } from "../../alert-formatting" +import type { DispatchContext } from "../context" import type { EffectTransport, RenderInput, SecretConfigOf } from "../Transport" type Config = SecretConfigOf<"chat"> @@ -20,6 +21,13 @@ type Config = SecretConfigOf<"chat"> */ const MAX_TEMPLATED_BODY_CHARS = 1200 +/** What the card reads: no destination, so a direct message to a person renders the same card. */ +export interface ChatAlertCardInput { + readonly context: Omit + readonly templated: RenderInput["templated"] + readonly linkUrl: string +} + /** * The alert as one neutral alert card, which the connector renders in its own dialect — and * which is where its mention-neutralising lives, so a rule named `@everyone` pings nobody. @@ -29,7 +37,7 @@ const MAX_TEMPLATED_BODY_CHARS = 1200 * and a footer carrying the sparkline, the incident and the time. A rule's own template, when it * has one, replaces the title and the summary and drops the fields, as it does there. */ -export const buildChatAlertBlocks = (input: RenderInput): ReadonlyArray => { +export const buildChatAlertBlocks = (input: ChatAlertCardInput): ReadonlyArray => { const { context, templated, linkUrl } = input const group = displayGroupKey(context.groupKey) const card: Omit = { diff --git a/packages/backend/src/services/integrations/ChatWorkspaceService.test.ts b/packages/backend/src/services/integrations/ChatWorkspaceService.test.ts index 8100125599..465f74aa13 100644 --- a/packages/backend/src/services/integrations/ChatWorkspaceService.test.ts +++ b/packages/backend/src/services/integrations/ChatWorkspaceService.test.ts @@ -166,6 +166,7 @@ const listingTransport: ChatOutboundTransport = { whisper: unreachable, typing: unreachable, openThread: unreachable, + directMessage: unreachable, conversation: unreachable, history: unreachable, destinations: (workspaceId) => diff --git a/packages/backend/src/services/org/OrganizationService.ts b/packages/backend/src/services/org/OrganizationService.ts index a68ca4a9d7..1b07d0606d 100644 --- a/packages/backend/src/services/org/OrganizationService.ts +++ b/packages/backend/src/services/org/OrganizationService.ts @@ -31,6 +31,7 @@ import { AgentFeedback, AlertDeliveryEvents, AlertEscalationRuns, + AlertMemberNotifications, AlertIncidentEvents, AlertDestinations, AlertIncidents, @@ -148,6 +149,7 @@ const ORG_SCOPED_TABLES: ReadonlyArray = [ // Before its incident: an event row references it. AlertIncidentEvents, AlertEscalationRuns, + AlertMemberNotifications, AlertIncidents, EscalationChains, // Before its schedule: an override row references it. diff --git a/packages/chat-platform/src/connectors/discord/outbound.test.ts b/packages/chat-platform/src/connectors/discord/outbound.test.ts index 7f30e09f43..c19d9f857b 100644 --- a/packages/chat-platform/src/connectors/discord/outbound.test.ts +++ b/packages/chat-platform/src/connectors/discord/outbound.test.ts @@ -3,7 +3,7 @@ * on demand: a 429, and a rejection that has to reach the driver as a typed failure. */ import { assert, describe, expect, it } from "@effect/vitest" -import { Effect, Fiber, Layer, String as Str } from "effect" +import { Effect, Fiber, Layer, Schema, String as Str } from "effect" import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/http" import { TestClock } from "effect/testing" import type { InboundAction, InboundMessage } from "../../ingress" @@ -503,3 +503,62 @@ describe("discord transport", () => { }).pipe(Effect.provide(http.layer)) }) }) + +const decodeRecipient = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Struct({ recipient_id: Schema.String })), +) + +describe("discord direct messages", () => { + it.effect("opens the bot's DM channel with the person, then posts into it", () => { + const http = stub([ + { status: 200, body: '{"id":"dm_42","type":1}' }, + { status: 200, body: '{"id":"m9","channel_id":"dm_42"}' }, + ]) + return Effect.gen(function* () { + const transport = yield* discordOutbound.transport + const dm = yield* transport.directMessage("guild_1", "user_ben") + expect(dm).toEqual({ workspaceId: "guild_1", channelId: "dm_42" }) + yield* transport.post(dm, [{ kind: "prose", markdown: "you are paged" }]) + + const [open, post] = http.seen + if (open === undefined || post === undefined) return assert.fail("expected two requests") + expect([open.method, open.url]).toEqual([ + "POST", + "https://discord.com/api/v10/users/@me/channels", + ]) + if (open.body._tag !== "Uint8Array") return assert.fail("expected a JSON body") + expect(decodeRecipient(new TextDecoder().decode(open.body.body))).toEqual({ + recipient_id: "user_ben", + }) + expect(post.url).toBe("https://discord.com/api/v10/channels/dm_42/messages") + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("reads a person who blocks the bot or shares no server with it as a permanent refusal", () => + Effect.forEach([50007, 50278], (code) => { + const http = stub([ + { status: 403, body: `{"message":"Cannot send messages to this user","code":${code}}` }, + ]) + return Effect.gen(function* () { + const transport = yield* discordOutbound.transport + const error = yield* Effect.flip( + transport.post({ workspaceId: "guild_1", channelId: "dm_42" }, [ + { kind: "prose", markdown: "x" }, + ]), + ) + expect([code, error.reason, error.status]).toEqual([code, "rejected", 403]) + }).pipe(Effect.provide(http.layer)) + }), + ) + + it.effect("leaves opening DMs too fast retryable", () => { + const http = stub([ + { status: 400, body: '{"message":"You are opening direct messages too fast.","code":40003}' }, + ]) + return Effect.gen(function* () { + const transport = yield* discordOutbound.transport + const error = yield* Effect.flip(transport.directMessage("guild_1", "user_ben")) + expect([error.operation, error.reason, error.status]).toEqual(["direct_message", undefined, 400]) + }).pipe(Effect.provide(http.layer)) + }) +}) diff --git a/packages/chat-platform/src/connectors/discord/outbound.ts b/packages/chat-platform/src/connectors/discord/outbound.ts index a4bd645816..d5eb9ac8d2 100644 --- a/packages/chat-platform/src/connectors/discord/outbound.ts +++ b/packages/chat-platform/src/connectors/discord/outbound.ts @@ -143,6 +143,33 @@ const reasonForStatus = (status: number): ChatOutboundFailureReason | undefined return undefined } +/** + * Discord's JSON error codes that decide a failure over its status. A direct message to somebody + * who blocks them (50007) or shares no server with the bot (50278) never goes through; opening + * DMs too fast (40003) is a rate limit worth retrying. + */ +const PERMANENT_ERROR_CODES: ReadonlySet = new Set([50007, 50278]) +const RETRYABLE_ERROR_CODES: ReadonlySet = new Set([40003]) + +const decodeErrorCode = Schema.decodeUnknownOption(Schema.Struct({ code: Schema.Number })) + +const reasonForFailure = ( + status: number, + code: Option.Option, +): ChatOutboundFailureReason | undefined => + Option.match(code, { + onNone: () => reasonForStatus(status), + onSome: (value) => + PERMANENT_ERROR_CODES.has(value) + ? "rejected" + : RETRYABLE_ERROR_CODES.has(value) + ? undefined + : reasonForStatus(status), + }) + +/** The DM channel `POST /users/@me/channels` opens or returns. */ +const DirectMessageChannel = Schema.Struct({ id: Schema.String }) + /** * One earlier message, as `GET /channels/{id}/messages` answers it. * @@ -289,11 +316,24 @@ export const discordOutbound: ChatOutbound= 300) { - const reason = reasonForStatus(response.status) - return yield* failed(operation, `Discord answered ${response.status}`, { - status: response.status, - ...(reason === undefined ? undefined : { reason }), - }) + // Best effort: a body that is not Discord's error JSON leaves the status to decide. + const code = yield* response.json.pipe( + Effect.map((json) => Option.map(decodeErrorCode(json), (body) => body.code)), + Effect.orElseSucceed(() => Option.none()), + ) + if (Option.isSome(code)) + yield* Effect.annotateCurrentSpan({ "error.type": String(code.value) }) + const reason = reasonForFailure(response.status, code) + return yield* failed( + operation, + Option.isSome(code) + ? `Discord answered ${response.status} (code ${code.value})` + : `Discord answered ${response.status}`, + { + status: response.status, + ...(reason === undefined ? undefined : { reason }), + }, + ) } return response }) @@ -475,6 +515,29 @@ export const discordOutbound: ChatOutbound + failed("direct_message", "Discord's reply could not be read", { cause }), + ), + ) + const channel = yield* Schema.decodeUnknownEffect(DirectMessageChannel)(json).pipe( + Effect.mapError(() => failed("direct_message", "Discord answered with no DM channel id")), + ) + return { workspaceId, channelId: channel.id } satisfies ChatTarget + }), + /** * What was said in this channel before a message, newest first. * diff --git a/packages/chat-platform/src/connectors/hazel/outbound.test.ts b/packages/chat-platform/src/connectors/hazel/outbound.test.ts index e553cbba2b..1d3a3cfdfb 100644 --- a/packages/chat-platform/src/connectors/hazel/outbound.test.ts +++ b/packages/chat-platform/src/connectors/hazel/outbound.test.ts @@ -524,3 +524,16 @@ describe("hazel transport", () => { }).pipe(Effect.provide(http.layer)) }) }) + +describe("hazel direct messages", () => { + it.effect("refuses to page a person, permanently and without a call", () => { + const http = stub([]) + return Effect.gen(function* () { + const transport = yield* hazelOutbound.transport + const error = yield* Effect.flip(transport.directMessage("org_1", "user_ben")) + expect([error.operation, error.reason]).toEqual(["direct_message", "rejected"]) + expect(error.message).toContain("does not support direct messages") + expect(http.seen).toHaveLength(0) + }).pipe(Effect.provide(http.layer)) + }) +}) diff --git a/packages/chat-platform/src/connectors/hazel/outbound.ts b/packages/chat-platform/src/connectors/hazel/outbound.ts index a1cf71b039..56158525bf 100644 --- a/packages/chat-platform/src/connectors/hazel/outbound.ts +++ b/packages/chat-platform/src/connectors/hazel/outbound.ts @@ -478,6 +478,14 @@ export const hazelOutbound: ChatOutbound Effect.void, + /** Hazel has no direct message a bot can open, so paging a person here always fails. */ + directMessage: () => + Effect.fail( + failed("direct_message", "Hazel does not support direct messages from Maple", { + reason: "rejected", + }), + ), + openThread, /** diff --git a/packages/chat-platform/src/connectors/slack/outbound.test.ts b/packages/chat-platform/src/connectors/slack/outbound.test.ts index 868ee20831..341b831dbe 100644 --- a/packages/chat-platform/src/connectors/slack/outbound.test.ts +++ b/packages/chat-platform/src/connectors/slack/outbound.test.ts @@ -615,3 +615,31 @@ describe("slack transport", () => { }).pipe(Effect.provide(http.layer)) }) }) + +describe("slack direct messages", () => { + it.effect("addresses a person by their user id, which chat.postMessage opens a DM for", () => { + const http = stub([{ status: 200, body: '{"ok":true,"channel":"D1","ts":"1700000000.000400"}' }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + const dm = yield* transport.directMessage("T1", "U_BEN") + // Opening one costs no call: only the post goes out. + expect(http.seen).toHaveLength(0) + yield* transport.post(dm, [{ kind: "prose", markdown: "you are paged" }]) + expect(Arr.getUnsafe(http.seen, 0).url).toBe("https://slack.com/api/chat.postMessage") + expect(sentBody(Arr.getUnsafe(http.seen, 0))).toMatchObject({ channel: "U_BEN" }) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("reads a person who turned off the app's messages, or left, as a permanent refusal", () => + Effect.forEach(["messages_tab_disabled", "user_not_found", "channel_not_found"], (code) => { + const http = stub([{ status: 200, body: `{"ok":false,"error":"${code}"}` }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + const dm = yield* transport.directMessage("T1", "U_GONE") + const error = yield* Effect.flip(transport.post(dm, [{ kind: "prose", markdown: "paged" }])) + expect([code, error.reason]).toEqual([code, "not_found"]) + expect(http.seen).toHaveLength(1) + }).pipe(Effect.provide(http.layer)) + }), + ) +}) diff --git a/packages/chat-platform/src/connectors/slack/outbound.ts b/packages/chat-platform/src/connectors/slack/outbound.ts index f0f2ea9952..a76464dd4e 100644 --- a/packages/chat-platform/src/connectors/slack/outbound.ts +++ b/packages/chat-platform/src/connectors/slack/outbound.ts @@ -173,7 +173,8 @@ const FAILURE_REASONS: ReadonlyMap = new Map( (code) => [code, "auth"] as const, ), ...Arr.map( - ["channel_not_found", "not_in_channel", "is_archived"], + // The last two answer a direct message: the person turned off the app's messages tab, or left. + ["channel_not_found", "not_in_channel", "is_archived", "messages_tab_disabled", "user_not_found"], (code) => [code, "not_found"] as const, ), ...Arr.map( @@ -492,6 +493,13 @@ export const slackOutbound: ChatOutbound Effect.void, + /** + * `chat.postMessage` takes a user id as its `channel` and opens the direct message itself, + * with only `chat:write`, so there is nothing to call here. + */ + directMessage: (workspaceId: string, userId: string) => + Effect.succeed({ workspaceId, channelId: userId } satisfies ChatTarget), + /** * A Slack thread is not an object — it is every message that carries the anchor's `ts` as * its `thread_ts` — so opening one is nothing to do and the anchor's own id is its diff --git a/packages/chat-platform/src/driver.test.ts b/packages/chat-platform/src/driver.test.ts index aaed22d143..e19efc2743 100644 --- a/packages/chat-platform/src/driver.test.ts +++ b/packages/chat-platform/src/driver.test.ts @@ -72,6 +72,7 @@ const recorder = (maxMessageChars = 500, liveMessages = false): Recorder => { typing: (typingTarget) => Effect.sync(() => void typing.push(typingTarget.channelId)), // A platform where a thread is just replies to a message opens one without any I/O. openThread: (request) => Effect.succeed(request.anchorMessageId), + directMessage: () => Effect.die("the driver opened a direct message"), // Neither is the driver's concern: by the time a turn is being rendered, the conversation // it belongs to has been decided and the model has already been given it. conversation: () => Effect.die("the driver asked which conversation this is"), diff --git a/packages/chat-platform/src/outbound.ts b/packages/chat-platform/src/outbound.ts index 61b5030daf..7c03b4bbc6 100644 --- a/packages/chat-platform/src/outbound.ts +++ b/packages/chat-platform/src/outbound.ts @@ -235,6 +235,14 @@ export interface ChatOutboundTransport { readonly destinations: ( workspaceId: string, ) => Effect.Effect, ChatOutboundError> + /** + * Where a direct message to one person goes, for paging them. `userId` is the platform's own + * id for the account. A platform that cannot open one fails with reason `rejected`. + */ + readonly directMessage: ( + workspaceId: string, + userId: string, + ) => Effect.Effect } /** @@ -279,6 +287,7 @@ export class ChatOutboundError extends Schema.TaggedError()( "thread", "history", "destinations", + "direct_message", ]), /** The platform's HTTP status, where the failure had one. */ status: Schema.optionalKey(Schema.Finite), diff --git a/packages/db/drizzle/20261106124130_alert_member_notifications/migration.sql b/packages/db/drizzle/20261106124130_alert_member_notifications/migration.sql new file mode 100644 index 0000000000..3d7cc11344 --- /dev/null +++ b/packages/db/drizzle/20261106124130_alert_member_notifications/migration.sql @@ -0,0 +1,33 @@ +-- metadata +CREATE TABLE IF NOT EXISTS "alert_member_notifications" ( + "id" text NOT NULL, + "org_id" text NOT NULL, + "incident_id" text NOT NULL, + "run_id" text NOT NULL, + "user_id" text NOT NULL, + "channel" text NOT NULL, + "status" text NOT NULL, + "attempt_number" integer NOT NULL DEFAULT 1, + "scheduled_at" timestamp with time zone NOT NULL, + "claimed_at" timestamp with time zone, + "claim_expires_at" timestamp with time zone, + "payload_json" jsonb NOT NULL, + "dedupe_key" text NOT NULL, + "error_message" text, + "attempted_at" timestamp with time zone, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + CONSTRAINT "alert_member_notifications_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +-- metadata +CREATE UNIQUE INDEX IF NOT EXISTS "alert_member_notifications_dedupe_idx" ON "alert_member_notifications" USING btree ("dedupe_key"); +--> statement-breakpoint +-- metadata +CREATE INDEX IF NOT EXISTS "alert_member_notifications_due_idx" ON "alert_member_notifications" USING btree ("status", "scheduled_at"); +--> statement-breakpoint +-- metadata +CREATE INDEX IF NOT EXISTS "alert_member_notifications_org_incident_idx" ON "alert_member_notifications" USING btree ("org_id", "incident_id"); +--> statement-breakpoint +-- metadata +ALTER TABLE "alert_member_notifications" ADD CONSTRAINT "alert_member_notifications_incident_id_alert_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "alert_incidents" ("id") ON DELETE CASCADE; diff --git a/packages/db/drizzle/20261106124130_alert_member_notifications/snapshot.json b/packages/db/drizzle/20261106124130_alert_member_notifications/snapshot.json new file mode 100644 index 0000000000..5425148b8e --- /dev/null +++ b/packages/db/drizzle/20261106124130_alert_member_notifications/snapshot.json @@ -0,0 +1,19980 @@ +{ + "version": "1", + "dialect": "postgres", + "id": "b5b692b8eb391ba00e85cc4ae13a3201d0319b2eb6c46b17f0a662f7ff617c1d", + "prevIds": [ + "745e388d729d6166039805657271a0e44c130e28bae5c84d8d131b9ec0167eff" + ], + "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_escalation_runs", + "primaryKey": { + "name": "alert_escalation_runs_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_incident_events", + "primaryKey": { + "name": "alert_incident_events_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_member_notifications", + "primaryKey": { + "name": "alert_member_notifications_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": "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": "escalation_chains", + "primaryKey": { + "name": "escalation_chains_pkey", + "columns": [ + "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": "oncall_overrides", + "primaryKey": { + "name": "oncall_overrides_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "oncall_schedules", + "primaryKey": { + "name": "oncall_schedules_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": "slos", + "primaryKey": { + "name": "slos_pkey", + "columns": [ + "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": "payload_json", + "position": 18, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "created_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "updated_at", + "position": 20, + "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_escalation_runs", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "incident_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "chain_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "chain_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "severity", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "steps_json", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "repeat_count", + "position": 7, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "step_index", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "pass_index", + "position": 9, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "next_step_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "status", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "payload_json", + "position": 12, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "steps_sent", + "position": 13, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "last_step_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "ended_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "created_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "updated_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "incident_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "actor_user_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "actor_name", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "source", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "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": "acknowledged_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "acknowledged_by_user_id", + "position": 25, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "acknowledged_by_name", + "position": 26, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "acknowledged_via", + "position": 27, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "error_issue_id", + "position": 28, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "created_at", + "position": 29, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "updated_at", + "position": 30, + "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_member_notifications", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "incident_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "run_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "user_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "channel", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "status", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "attempt_number", + "position": 7, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "scheduled_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "claimed_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "claim_expires_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "payload_json", + "position": 11, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "dedupe_key", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "error_message", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "attempted_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "updated_at", + "position": 16, + "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": "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": "escalation_chains", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "description", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "steps_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "repeat_count", + "position": 5, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "updated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "created_by", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "updated_by", + "position": 9, + "type": "text", + "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": "oncall_overrides", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "schedule_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "user_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "starts_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "ends_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "created_by", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "description", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "time_zone", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "layers_json", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "updated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "created_by", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "updated_by", + "position": 9, + "type": "text", + "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": "slos", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "description", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "service_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "environments_json", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "sli_json", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "target_percent", + "position": 7, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "window_days", + "position": 8, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "alert_rule_id", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "created_by", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "updated_by", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "created_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "updated_at", + "position": 13, + "type": "timestamp with time zone", + "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_escalation_runs", + "name": "alert_escalation_runs_live_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"next_step_at\"" + ], + "where": "\"status\" in ('active', 'acknowledged')" + }, + { + "kind": "index", + "table": "alert_escalation_runs", + "name": "alert_escalation_runs_live_incident_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"incident_id\"" + ], + "where": "\"status\" IN ('active', 'acknowledged')" + }, + { + "kind": "index", + "table": "alert_escalation_runs", + "name": "alert_escalation_runs_org_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incident_events", + "name": "alert_incident_events_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_id\"", + "\"created_at\"" + ], + "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_member_notifications", + "name": "alert_member_notifications_dedupe_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"dedupe_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_member_notifications", + "name": "alert_member_notifications_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"scheduled_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_member_notifications", + "name": "alert_member_notifications_org_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_id\"" + ], + "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": "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": "escalation_chains", + "name": "escalation_chains_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "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": "oncall_overrides", + "name": "oncall_overrides_org_schedule_ends_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"schedule_id\"", + "\"ends_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oncall_schedules", + "name": "oncall_schedules_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_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": "slos", + "name": "slos_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "slos", + "name": "slos_org_name_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"name\"" + ], + "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_escalation_runs", + "name": "alert_escalation_runs_incident_id_alert_incidents_id_fk", + "columns": [ + "incident_id" + ], + "foreignTable": "alert_incidents", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "alert_incident_events", + "name": "alert_incident_events_incident_id_alert_incidents_id_fk", + "columns": [ + "incident_id" + ], + "foreignTable": "alert_incidents", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "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": "alert_member_notifications", + "name": "alert_member_notifications_incident_id_alert_incidents_id_fk", + "columns": [ + "incident_id" + ], + "foreignTable": "alert_incidents", + "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": "oncall_overrides", + "name": "oncall_overrides_schedule_id_oncall_schedules_id_fk", + "columns": [ + "schedule_id" + ], + "foreignTable": "oncall_schedules", + "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/escalation-chains.ts b/packages/db/src/tables/escalation-chains.ts index c4ce31df5d..3ba078082b 100644 --- a/packages/db/src/tables/escalation-chains.ts +++ b/packages/db/src/tables/escalation-chains.ts @@ -1,6 +1,17 @@ import * as PG from "@maple-dev/effect-orm/postgres" -import { AlertEscalationRunStatus, AlertSeverity, EscalationSteps } from "@maple/domain/http" -import { AlertEscalationRunId, AlertIncidentId, EscalationChainId, OrgId } from "@maple/domain/primitives" +import { + AlertEscalationRunStatus, + AlertIncidentPageStatus, + AlertSeverity, + EscalationSteps, +} from "@maple/domain/http" +import { + AlertEscalationRunId, + AlertIncidentId, + EscalationChainId, + OrgId, + UserId, +} from "@maple/domain/primitives" import { Schema } from "effect" import { AlertIncidents } from "./alerts" @@ -86,3 +97,52 @@ export const AlertEscalationRuns = PG.table("alert_escalation_runs", { export type EscalationChainRow = PG.SelectRowOf export type AlertEscalationRunRow = PG.SelectRowOf + +/** + * Outbox of pages to people: an escalation step that targets a user or an on-call schedule queues + * one row per channel (`email`, or a chat connector id for a direct message) in the transaction + * that moves the run. The alert scheduler drains it after the destination queue. + */ +export const AlertMemberNotifications = PG.table("alert_member_notifications", { + columns: { + id: PG.text, + orgId: PG.column(PG.brand(PG.text, OrgId), { name: "org_id" }), + incidentId: PG.column(PG.brand(PG.text, AlertIncidentId), { name: "incident_id" }), + runId: PG.column(PG.brand(PG.text, AlertEscalationRunId), { name: "run_id" }), + userId: PG.column(PG.brand(PG.text, UserId), { name: "user_id" }), + /** `email`, or the chat connector a direct message goes through. */ + channel: PG.text, + status: PG.brand(PG.text, AlertIncidentPageStatus), + attemptNumber: PG.column(PG.int4, { name: "attempt_number", default: 1 }), + scheduledAt: PG.column(PG.timestamptzMillis, { name: "scheduled_at" }), + claimedAt: PG.column(PG.nullable(PG.timestamptzMillis), { name: "claimed_at" }), + claimExpiresAt: PG.column(PG.nullable(PG.timestamptzMillis), { name: "claim_expires_at" }), + /** The step's notification as its destinations got it, plus `recipient`. */ + payloadJson: PG.column(PG.jsonb(Schema.Record(Schema.String, Schema.Unknown)), { + name: "payload_json", + }), + /** `{runId}:{pass}:{step}:{userId}:{channel}`: one page per person, channel and step. */ + dedupeKey: PG.column(PG.text, { name: "dedupe_key" }), + errorMessage: PG.column(PG.nullable(PG.text), { name: "error_message" }), + attemptedAt: PG.column(PG.nullable(PG.timestamptzMillis), { name: "attempted_at" }), + createdAt: PG.column(PG.timestamptzMillis, { name: "created_at" }), + updatedAt: PG.column(PG.timestamptzMillis, { name: "updated_at" }), + }, + primaryKey: ["id"], + indexes: [ + PG.uniqueIndex("alert_member_notifications_dedupe_idx", ["dedupeKey"]), + PG.index("alert_member_notifications_due_idx", ["status", "scheduledAt"]), + PG.index("alert_member_notifications_org_incident_idx", ["orgId", "incidentId"]), + ], + foreignKeys: [ + PG.foreignKey({ + columns: ["incidentId"], + references: AlertIncidents, + foreignColumns: ["id"], + onDelete: "cascade", + }), + ], + tenantColumn: "orgId", +}) + +export type AlertMemberNotificationRow = PG.SelectRowOf diff --git a/packages/db/src/tables/index.ts b/packages/db/src/tables/index.ts index dbbf8214b6..7ea9d18d51 100644 --- a/packages/db/src/tables/index.ts +++ b/packages/db/src/tables/index.ts @@ -41,7 +41,7 @@ import { ErrorIssueVerifications, } from "./errors" import { IssueEscalationPolicies, IssueEscalations } from "./escalations" -import { AlertEscalationRuns, EscalationChains } from "./escalation-chains" +import { AlertEscalationRuns, AlertMemberNotifications, EscalationChains } from "./escalation-chains" import { OnCallOverrides, OnCallSchedules } from "./oncall-schedules" import { AwsConnections } from "./aws-connections" import { GcpConnectors } from "./gcp-connectors" @@ -206,6 +206,7 @@ export const allTables: ReadonlyArray> = [ IssueEscalations, EscalationChains, AlertEscalationRuns, + AlertMemberNotifications, OnCallSchedules, OnCallOverrides, GcpConnectors, diff --git a/packages/domain/src/http/alerts.ts b/packages/domain/src/http/alerts.ts index ca02bdc1f4..215bc35f6b 100644 --- a/packages/domain/src/http/alerts.ts +++ b/packages/domain/src/http/alerts.ts @@ -151,12 +151,14 @@ export type AlertIncidentAckSource = Schema.Schema.Type(" errorIssueId: Schema.NullOr(ErrorIssueId), }) {} +/** Where a page to a person stands: queued, out, refused, or dropped because the incident moved on. */ +export const AlertIncidentPageStatus = Schema.Literals([ + "queued", + "sending", + "sent", + "failed", + "skipped", +]).annotate({ + identifier: "@maple/AlertIncidentPageStatus", + title: "Alert Incident Page Status", +}) +export type AlertIncidentPageStatus = Schema.Schema.Type + +/** A person an escalation step paged, on one channel. Never carries the address. */ +export const AlertIncidentPage = Schema.Struct({ + userId: UserId, + /** The member's display name, or null when the directory could not name them. */ + name: Schema.NullOr(Schema.String), + /** `email`, or the chat connector of the direct message (`slack`, `discord`). */ + channel: Schema.String, + status: AlertIncidentPageStatus, + /** Why it was not sent, for a `failed` or `skipped` page. */ + errorMessage: Schema.NullOr(Schema.String), +}) +export type AlertIncidentPage = Schema.Schema.Type + export class AlertIncidentEventDocument extends Schema.Class( "AlertIncidentEventDocument", )({ @@ -1048,6 +1076,8 @@ export class AlertIncidentEventDocument extends Schema.Class +/** The destinations, users and schedules `steps` names, each once. */ +export const escalationStepTargetIds = ( + steps: ReadonlyArray<{ readonly targets: ReadonlyArray }>, +) => { + const targets = Arr.flatMap(steps, (step) => step.targets) + return { + destinationIds: Arr.dedupe( + Arr.flatMap(targets, (t) => (t.type === "destination" ? [t.destinationId] : [])), + ), + userIds: Arr.dedupe(Arr.flatMap(targets, (t) => (t.type === "user" ? [t.userId] : []))), + scheduleIds: Arr.dedupe(Arr.flatMap(targets, (t) => (t.type === "schedule" ? [t.scheduleId] : []))), + } +} + export const EscalationWaitMinutes = Schema.Int.check( Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(MAX_ESCALATION_WAIT_MINUTES), diff --git a/packages/domain/src/http/v2/alert-incidents.ts b/packages/domain/src/http/v2/alert-incidents.ts index 6b73520577..2989402fba 100644 --- a/packages/domain/src/http/v2/alert-incidents.ts +++ b/packages/domain/src/http/v2/alert-incidents.ts @@ -7,6 +7,7 @@ import { AlertIncidentAckSource, AlertIncidentEventType, AlertIncidentHoldReason, + AlertIncidentPageStatus, AlertIncidentStatus, AlertSeverity, AlertSignalType, @@ -167,7 +168,7 @@ export const V2AlertIncidentEvent = Schema.Struct({ incident_id: AlertIncidentPublicId, type: AlertIncidentEventType.annotate({ description: - "`acknowledged` or `unacknowledged` by a person, or `ack_cleared` when the scheduler cleared an acknowledgement because the incident escalated to a higher severity.", + "`acknowledged` or `unacknowledged` by a person, `ack_cleared` when the scheduler cleared an acknowledgement because the incident escalated to a higher severity, or `paged` when an escalation step paged a person (see `page`).", examples: ["acknowledged"], }), actor: Schema.NullOr(V2AlertIncidentActor).annotate({ @@ -176,6 +177,25 @@ export const V2AlertIncidentEvent = Schema.Struct({ source: Schema.NullOr(AlertIncidentAckSource).annotate({ description: "Where it came from, or `null` for a scheduler-written event.", }), + page: Schema.optionalKey( + Schema.Struct({ + user_id: UserId.annotate({ description: "The member paged." }), + name: Schema.NullOr(Schema.String).annotate({ + description: "Their display name, or `null` when the member directory could not name them.", + }), + channel: Schema.String.annotate({ + description: "`email`, or the chat platform of the direct message (`slack`, `discord`).", + examples: ["email"], + }), + status: AlertIncidentPageStatus.annotate({ + description: + "`sent`; `queued` or `sending` while on its way; `failed` when the channel refused it; `skipped` when the incident was acknowledged or resolved first.", + }), + error_message: Schema.NullOr(Schema.String).annotate({ + description: "Why a `failed` or `skipped` page was not delivered.", + }), + }).annotate({ description: "Set on a `paged` event. Never carries the address." }), + ), created_at: Timestamp, }).annotate({ identifier: "AlertIncidentEvent", diff --git a/packages/domain/src/http/v2/escalation-chains.ts b/packages/domain/src/http/v2/escalation-chains.ts index 786633b7d9..f98d6d3806 100644 --- a/packages/domain/src/http/v2/escalation-chains.ts +++ b/packages/domain/src/http/v2/escalation-chains.ts @@ -1,6 +1,11 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api" import { Array as Arr, Schema } from "effect" -import { AlertForbiddenError, AlertPersistenceError, AlertValidationError } from "../alerts" +import { + AlertForbiddenError, + AlertMemberDirectoryUnavailableError, + AlertPersistenceError, + AlertValidationError, +} from "../alerts" import { EscalationChainDescription, EscalationChainInUseError, @@ -17,21 +22,47 @@ import { AuthorizationV2 } from "./auth" import { wireExample, ListOf, ListQuery, Timestamp } from "./envelopes" import { V2ParameterInvalid } from "./errors" import { publicErrors } from "./public-error" -import { AlertDestinationPublicId, AlertRulePublicId, EscalationChainPublicId } from "./resource-ids" +import { UserId } from "../../primitives" +import { + AlertDestinationPublicId, + AlertRulePublicId, + EscalationChainPublicId, + OnCallSchedulePublicId, +} from "./resource-ids" export { EscalationChainPublicId } from "./resource-ids" -export const V2EscalationTarget = Schema.Struct({ - type: Schema.Literal("destination").annotate({ - description: 'What the step notifies. Always `"destination"` for now.', - }), +const V2EscalationDestinationTarget = Schema.Struct({ + type: Schema.Literal("destination").annotate({ description: "Notify an alert destination." }), destination_id: AlertDestinationPublicId.annotate({ description: "The alert destination (`dest_…`) this step notifies.", }), -}).annotate({ +}).annotate({ identifier: "EscalationDestinationTarget", title: "Destination target" }) + +const V2EscalationUserTarget = Schema.Struct({ + type: Schema.Literal("user").annotate({ + description: "Page a member by email and by direct message on each chat account they linked.", + }), + user_id: UserId.annotate({ description: "A Maple user id (`user_…`) of a member of your organization." }), +}).annotate({ identifier: "EscalationUserTarget", title: "User target" }) + +const V2EscalationScheduleTarget = Schema.Struct({ + type: Schema.Literal("schedule").annotate({ + description: "Page whoever is on call on the schedule when the step fires.", + }), + schedule_id: OnCallSchedulePublicId.annotate({ + description: "The on-call schedule (`onc_…`). A gap in the schedule pages nobody for this target.", + }), +}).annotate({ identifier: "EscalationScheduleTarget", title: "Schedule target" }) + +export const V2EscalationTarget = Schema.Union([ + V2EscalationDestinationTarget, + V2EscalationUserTarget, + V2EscalationScheduleTarget, +]).annotate({ identifier: "EscalationTarget", title: "Escalation target", - description: "Who one escalation step notifies.", + description: "Who one escalation step notifies: a destination, a member, or an on-call schedule.", }) export type V2EscalationTarget = Schema.Schema.Type @@ -77,7 +108,7 @@ const escalationChainExample = { wait_minutes: 10, }, { - targets: [{ type: "destination", destination_id: "dest_cTp8s4DaBKsNmcma6yJGSn" }], + targets: [{ type: "schedule", schedule_id: "onc_cTp8s4DaBKsNmcma6yJGSn" }], wait_minutes: 15, }, ], @@ -124,7 +155,8 @@ export const V2EscalationChainCreateParams = Schema.Struct({ }).annotate({ identifier: "EscalationChainCreateParams", title: "Create escalation chain", - description: "Every destination a step names must exist in your organization.", + description: + "Every destination and schedule a step names must exist in your organization, and every user must be a member.", examples: [ wireExample({ name: escalationChainExample.name, @@ -173,13 +205,15 @@ const EscalationChainList = ListOf(V2EscalationChain).annotate({ description: "A cursor-paginated page of escalation chains, by name.", }) -const [alertForbidden, alertValidation, alertPersistence, chainNotFound, chainInUse] = publicErrors( - AlertForbiddenError, - AlertValidationError, - AlertPersistenceError, - EscalationChainNotFoundError, - EscalationChainInUseError, -) +const [alertForbidden, alertValidation, alertPersistence, chainNotFound, chainInUse, directoryUnavailable] = + publicErrors( + AlertForbiddenError, + AlertValidationError, + AlertPersistenceError, + EscalationChainNotFoundError, + EscalationChainInUseError, + AlertMemberDirectoryUnavailableError, + ) export class V2EscalationChainsApiGroup extends HttpApiGroup.make("escalationChains") .add( @@ -214,13 +248,13 @@ export class V2EscalationChainsApiGroup extends HttpApiGroup.make("escalationCha HttpApiEndpoint.post("create", "/", { payload: V2EscalationChainCreateParams, success: V2EscalationChain, - error: [alertForbidden, alertValidation, alertPersistence], + error: [alertForbidden, alertValidation, alertPersistence, directoryUnavailable], }).annotateMerge( OpenApi.annotations({ identifier: "createEscalationChain", summary: "Create an escalation chain", description: - "Creates a chain. Attach it to an alert rule through a severity tier's `escalation_chain_id`. Fails with an `invalid_request_error` when a step names an unknown destination. Requires a role that can manage alerts and the `alerts:write` scope.", + "Creates a chain. Attach it to an alert rule through a severity tier's `escalation_chain_id`. Fails with an `invalid_request_error` when a step names an unknown destination or schedule, or a user who is not a member. Requires a role that can manage alerts and the `alerts:write` scope.", }), ), ) @@ -229,7 +263,7 @@ export class V2EscalationChainsApiGroup extends HttpApiGroup.make("escalationCha params: { id: EscalationChainPublicId }, payload: V2EscalationChainUpdateParams, success: V2EscalationChain, - error: [alertForbidden, alertValidation, alertPersistence, chainNotFound], + error: [alertForbidden, alertValidation, alertPersistence, chainNotFound, directoryUnavailable], }).annotateMerge( OpenApi.annotations({ identifier: "updateEscalationChain", diff --git a/packages/domain/src/http/v2/v2-contract.test.ts b/packages/domain/src/http/v2/v2-contract.test.ts index a09179b591..de406059fd 100644 --- a/packages/domain/src/http/v2/v2-contract.test.ts +++ b/packages/domain/src/http/v2/v2-contract.test.ts @@ -446,8 +446,9 @@ describe("V2 alerts wire format", () => { ).toThrow() }) - it("encodes an escalation chain with esc_/dest_/alrt_ ids and snake_case steps", () => { + it("encodes an escalation chain with esc_/dest_/onc_/alrt_ ids, user targets and snake_case steps", () => { const CHAIN_UUID = "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b" + const SCHEDULE_UUID = "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d" const chainWire = { id: encodePublicId("esc", CHAIN_UUID), object: "escalation_chain", @@ -458,6 +459,13 @@ describe("V2 alerts wire format", () => { targets: [{ type: "destination", destination_id: encodePublicId("dest", DEST_UUID) }], wait_minutes: 10, }, + { + targets: [ + { type: "schedule", schedule_id: encodePublicId("onc", SCHEDULE_UUID) }, + { type: "user", user_id: "user_ana" }, + ], + wait_minutes: 15, + }, ], repeat_count: 1, rule_ids: [encodePublicId("alrt", UUID)], @@ -466,7 +474,11 @@ describe("V2 alerts wire format", () => { } const chain = Schema.decodeUnknownSync(V2EscalationChain)(chainWire) expect(chain.id).toBe(CHAIN_UUID) - expect(chain.steps[0]?.targets[0]?.destination_id).toBe(DEST_UUID) + expect(chain.steps[0]?.targets[0]).toEqual({ type: "destination", destination_id: DEST_UUID }) + expect(chain.steps[1]?.targets).toEqual([ + { type: "schedule", schedule_id: SCHEDULE_UUID }, + { type: "user", user_id: "user_ana" }, + ]) expect(chain.rule_ids).toEqual([UUID]) expect(Schema.encodeSync(V2EscalationChain)(chain)).toEqual(chainWire) expect(() => Schema.decodeUnknownSync(V2EscalationChain)({ ...chainWire, steps: [] })).toThrow() @@ -476,6 +488,17 @@ describe("V2 alerts wire format", () => { steps: [{ ...chainWire.steps[0], wait_minutes: 0 }], }), ).toThrow() + expect(() => + Schema.decodeUnknownSync(V2EscalationChain)({ + ...chainWire, + steps: [ + { + targets: [{ type: "schedule", schedule_id: encodePublicId("dest", SCHEDULE_UUID) }], + wait_minutes: 5, + }, + ], + }), + ).toThrow() }) it("encodes an on-call schedule with onc_ ids, snake_case layers and Maple user ids", () => { diff --git a/packages/domain/src/mcp-outputs/alerts.ts b/packages/domain/src/mcp-outputs/alerts.ts index 3504f80d48..db900e5455 100644 --- a/packages/domain/src/mcp-outputs/alerts.ts +++ b/packages/domain/src/mcp-outputs/alerts.ts @@ -314,6 +314,19 @@ export const IncidentTimelineRow = Schema.Struct({ lastNotifiedAt: NullableString, acknowledgedAt: Schema.optionalKey(NullableString), acknowledgedBy: Schema.optionalKey(NullableString), + /** People escalation steps paged, oldest first. Never the address. */ + pages: Schema.optionalKey( + Schema.Array( + Schema.Struct({ + userId: Schema.String, + name: NullableString, + /** `email`, or the chat connector of the direct message. */ + channel: Schema.String, + status: Schema.String, + at: Schema.String, + }), + ), + ), }) export const AcknowledgeAlertIncidentOutput = Schema.Struct({ diff --git a/packages/domain/src/mcp-outputs/escalation-chains.ts b/packages/domain/src/mcp-outputs/escalation-chains.ts index f36193ef1f..7e821016d1 100644 --- a/packages/domain/src/mcp-outputs/escalation-chains.ts +++ b/packages/domain/src/mcp-outputs/escalation-chains.ts @@ -4,9 +4,12 @@ import { Schema } from "effect" const NullableString = Schema.NullOr(Schema.String) export const EscalationTargetRow = Schema.Struct({ - destinationId: Schema.String, - /** Null when the destination list could not be read or the destination is gone. */ + /** A destination, a member paged directly, or whoever is on call on a schedule. */ + kind: Schema.Literals(["destination", "user", "schedule"]), + id: Schema.String, + /** Null when the name could not be read or the target is gone. */ name: NullableString, + /** The destination's type; null for a user or schedule. */ type: NullableString, }) From 8168fa5f908a96cb0c6559175a66167703c98030 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:06:19 +0100 Subject: [PATCH 28/98] fix(web): the chain delete conflict reads well when the cached chain names no rules --- apps/web/src/page/alerts/hub/update.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 62e61d242e..90bae8251e 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -353,15 +353,16 @@ export const chainRuleNames = ( ) /** Why a delete was refused: the API's 409 names no rules, so they come from the chain's own list. */ -const inUseMessage = (model: Model, chainId: EscalationChainId): string => - Option.match( +// The cached chain can name no rules when one was attached after this tab read the list. +const inUseMessage = (model: Model, chainId: EscalationChainId): string => { + const names = Option.match( Array.findFirst(chainsOf(model), (chain) => chain.id === chainId), - { - onNone: () => "Alert rules still start this chain. Remove it from their severity tiers first.", - onSome: (chain) => - `Still used by ${Array.join(chainRuleNames(model, chain), ", ")}. Remove it from their severity tiers first.`, - }, + { onNone: () => [], onSome: (chain) => chainRuleNames(model, chain) }, ) + return names.length === 0 + ? "Alert rules still start this chain. Remove it from their severity tiers first." + : `Still used by ${Array.join(names, ", ")}. Remove it from their severity tiers first.` +} /** Runs the chain delete dialog's update; `Closed` forgets the target and its error. */ const updateDeleteChainDialog = (model: Model, step: ReturnType): Return => { From 46dd4dad2e65f947f708b081b5073f121a6bf18f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:14:26 +0100 Subject: [PATCH 29/98] fix(lint): effect/Array.findFirst for the delivery log's connector lookup --- apps/web/src/page/alerts/hub/view-routing.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/page/alerts/hub/view-routing.ts b/apps/web/src/page/alerts/hub/view-routing.ts index 5c2fd2f020..713f591000 100644 --- a/apps/web/src/page/alerts/hub/view-routing.ts +++ b/apps/web/src/page/alerts/hub/view-routing.ts @@ -456,7 +456,9 @@ const logRow = (h: H, model: Model, inputs: RoutingInputs, event: AlertDeliveryE const ev = eventTypeMeta[event.eventType] const status = deliveryStatusMeta[event.status] // Delivery events carry only the type; a `chat` destination's logo comes from its connector. - const chatConnector = inputs.destinations.find((d) => d.id === event.destinationId)?.chatConnector + const chatConnector = Option.getOrUndefined( + Arr.findFirst(inputs.destinations, (d) => d.id === event.destinationId), + )?.chatConnector return tableRow(h, [ tableCell(h, [ hstack(h, { gap: "1.5", as: "span" }, [ From 2d08f9b6d10608e167cb5b69351cbc3bb7dfd782 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:16:05 +0100 Subject: [PATCH 30/98] fix(web): editing an escalation chain keeps each step's member and schedule targets The dialog edits only destinations, so it now carries a step's other targets and sends them back unchanged; a step whose only targets are members or schedules still validates, and the step says who else it pages. --- .../alerts/escalation-dialog/dialog.test.ts | 28 +++++++++++++++ .../page/alerts/escalation-dialog/dialog.ts | 34 +++++++++++++------ .../src/page/alerts/escalation-dialog/view.ts | 24 +++++++++++-- .../alerts/shared/escalation-test-fixtures.ts | 26 +++++++++++--- 4 files changed, 95 insertions(+), 17 deletions(-) diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts index d3ff247bb9..a3acf562a1 100644 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts +++ b/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts @@ -83,4 +83,32 @@ describe("escalation chain dialog", () => { ) expect(full.steps).toHaveLength(10) }) + + it("an edit sends a step's member and schedule targets back, and a schedule alone is enough", () => { + const chain = chainOf({ + n: 2, + name: "Checkout on-call", + steps: [ + { destinations: [1], users: ["user_ben"], wait: 10 }, + { destinations: [], schedules: [1], wait: 15 }, + ], + }) + const renamed = run(openEdit(init("chain"), chain).model, [ + Message.ChangedName({ value: "Checkout" }), + ]) + const saving = update(renamed, Message.ClickedSave()) + expect(saving.commands?.[0]?.args).toMatchObject({ + payload: { + name: "Checkout", + steps: Arr.map(chain.steps, (step) => ({ targets: step.targets })), + }, + }) + const key = renamed.steps[0]?.key ?? "" + const withoutDestination = run(renamed, [ + Message.ClickedRemoveDestination({ key, id: asDestination(chainDestinationUuid(1)) }), + ]) + expect(Option.map(payloadOf(withoutDestination), ({ steps }) => steps[0].targets)).toEqual( + Option.some([{ type: "user", user_id: "user_ben" }]), + ) + }) }) diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.ts index 37dd74ee65..9dc89994eb 100644 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.ts +++ b/apps/web/src/page/alerts/escalation-dialog/dialog.ts @@ -14,6 +14,7 @@ import { V2EscalationChain, V2EscalationChainCreateParams, type V2EscalationStep, + V2EscalationTarget, } from "@maple/domain/http/v2" import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" import { Command, type Update } from "foldkit" @@ -30,6 +31,8 @@ import { saveEscalationChain } from "../shared/escalation-chains" export const StepDraft = Schema.Struct({ key: Schema.String, destinationIds: Schema.Array(AlertDestinationId), + /** Member and schedule targets: not editable here yet, so a save sends them back unchanged. */ + otherTargets: Schema.Array(V2EscalationTarget), waitMinutes: Schema.String, }) export type StepDraft = typeof StepDraft.Type @@ -58,6 +61,7 @@ export const DEFAULT_WAIT_MINUTES = "10" const emptyStep = (key: number): StepDraft => ({ key: `s${key}`, destinationIds: [], + otherTargets: [], waitMinutes: DEFAULT_WAIT_MINUTES, }) @@ -132,12 +136,21 @@ export const waitError = (raw: string): Option.Option => export const repeatError = (raw: string): Option.Option => isRepeat(numberOf(raw)) ? Option.none() : Option.some(`Repeat 0 to ${MAX_ESCALATION_REPEATS} times`) -export const destinationsError = (ids: ReadonlyArray): Option.Option => - ids.length === 0 +/** A step needs one target; its carried member and schedule targets count toward both limits. */ +export const targetsError = ( + step: Pick, +): Option.Option => { + const total = step.destinationIds.length + step.otherTargets.length + return total === 0 ? Option.some("Pick at least one destination") - : ids.length > MAX_ESCALATION_STEP_TARGETS - ? Option.some(`A step notifies at most ${MAX_ESCALATION_STEP_TARGETS} destinations`) - : Option.none() + : total <= MAX_ESCALATION_STEP_TARGETS + ? Option.none() + : Option.some( + step.otherTargets.length === 0 + ? `A step notifies at most ${MAX_ESCALATION_STEP_TARGETS} destinations` + : `A step notifies at most ${MAX_ESCALATION_STEP_TARGETS} targets, members and schedules included`, + ) +} export const stepsError = (steps: ReadonlyArray): Option.Option => steps.length === 0 @@ -147,13 +160,13 @@ export const stepsError = (steps: ReadonlyArray): Option.Option => { - const targets = Arr.map(step.destinationIds, (id) => ({ - type: "destination" as const, - destination_id: id, - })) + const targets: ReadonlyArray = [ + ...Arr.map(step.destinationIds, (id) => ({ type: "destination" as const, destination_id: id })), + ...step.otherTargets, + ] const minutes = numberOf(step.waitMinutes) return Arr.isReadonlyArrayNonEmpty(targets) && - Option.isNone(destinationsError(step.destinationIds)) && + Option.isNone(targetsError(step)) && minutes !== null && isWait(minutes) ? Option.some({ targets, wait_minutes: minutes }) @@ -246,6 +259,7 @@ export const openEdit = (model: Model, chain: V2EscalationChain): Return => destinationIds: Arr.flatMap(step.targets, (target) => target.type === "destination" ? [target.destination_id] : [], ), + otherTargets: Arr.filter(step.targets, (target) => target.type !== "destination"), waitMinutes: String(step.wait_minutes), })), repeatCount: String(chain.repeat_count), diff --git a/apps/web/src/page/alerts/escalation-dialog/view.ts b/apps/web/src/page/alerts/escalation-dialog/view.ts index e88a39ae78..8c5f7db4df 100644 --- a/apps/web/src/page/alerts/escalation-dialog/view.ts +++ b/apps/web/src/page/alerts/escalation-dialog/view.ts @@ -26,7 +26,6 @@ import { destinationPicker } from "../../kit/destination-picker" import { formatWait } from "../shared/escalation-chains" import { descriptionError, - destinationsError, menuIdOf, Message, type Model, @@ -35,6 +34,7 @@ import { repeatError, type StepDraft, stepsError, + targetsError, waitError, } from "./dialog" @@ -153,11 +153,30 @@ const waitLabel = (model: Model, index: number): string => : // The run ends as soon as the last step pages, so this wait only matters once it repeats. "Wait before repeating, in minutes (unused unless the chain repeats)" +const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}` + +/** The step's member and schedule targets, which this dialog keeps but cannot edit yet. */ +const carriedLine = (h: H, step: StepDraft): ReadonlyArray => { + const members = Arr.filter(step.otherTargets, (target) => target.type === "user").length + const schedules = Arr.filter(step.otherTargets, (target) => target.type === "schedule").length + const parts = [ + ...(members === 0 ? [] : [plural(members, "member", "members")]), + ...(schedules === 0 ? [] : [`whoever is on call on ${plural(schedules, "schedule", "schedules")}`]), + ] + return parts.length === 0 + ? [] + : [ + typography(h, { size: "xs", tone: "muted" }, [ + `Also pages ${Arr.join(parts, " and ")}. Saving keeps them.`, + ]), + ] +} + const stepBlock = (h: H, model: Model, inputs: ViewInputs, step: StepDraft, index: number): Html => { const count = model.steps.length const previous = index === 0 ? undefined : model.steps[index - 1] const wait = waitError(step.waitMinutes) - const destinations = shownError(model, destinationsError(step.destinationIds)) + const destinations = shownError(model, targetsError(step)) const key = step.key return panel(h, { padded: "sm", className: "gap-3", attributes: [h.Key(key)] }, [ hstack(h, { gap: "2", justify: "between" }, [ @@ -214,6 +233,7 @@ const stepBlock = (h: H, model: Model, inputs: ViewInputs, step: StepDraft, inde ? "No destinations exist yet. Add one in Routing first." : "Pick who this step pages", }), + ...carriedLine(h, step), ...Option.match(destinations, { onNone: () => [], onSome: (text) => [fieldErrorText(h, { attributes: [h.Role("alert")] }, [text])], diff --git a/apps/web/src/page/alerts/shared/escalation-test-fixtures.ts b/apps/web/src/page/alerts/shared/escalation-test-fixtures.ts index 11b81c8ec4..109b00485d 100644 --- a/apps/web/src/page/alerts/shared/escalation-test-fixtures.ts +++ b/apps/web/src/page/alerts/shared/escalation-test-fixtures.ts @@ -8,6 +8,7 @@ import { destinationRow, NOW } from "./test-rows" const pad = (n: number) => Str.padStart(12, "0")(String(n)) export const chainUuid = (n: number) => `0199a00e-0000-7000-8000-${pad(n)}` export const chainDestinationUuid = (n: number) => `0199a001-0000-7000-8000-${pad(100 + n)}` +export const chainScheduleUuid = (n: number) => `0199a00f-0000-7000-8000-${pad(200 + n)}` const NAMES = [ "Slack #alerts-api", @@ -32,7 +33,15 @@ export type ChainSpec = Readonly<{ name: string description?: string | null /** Each step's destinations, by `chainDestinationUuid` number, and its wait. */ - steps: ReadonlyArray; wait: number }>> + steps: ReadonlyArray< + Readonly<{ + destinations: ReadonlyArray + wait: number + /** On-call schedules, by `chainScheduleUuid` number, and member user ids. */ + schedules?: ReadonlyArray + users?: ReadonlyArray + }> + > repeat?: number ruleIds?: ReadonlyArray }> @@ -47,10 +56,17 @@ export const chainOf = (spec: ChainSpec): V2EscalationChain => name: spec.name, description: spec.description ?? null, steps: Arr.map(spec.steps, (step) => ({ - targets: Arr.map(step.destinations, (d) => ({ - type: "destination", - destination_id: encodePublicId("dest", chainDestinationUuid(d)), - })), + targets: [ + ...Arr.map(step.destinations, (d) => ({ + type: "destination", + destination_id: encodePublicId("dest", chainDestinationUuid(d)), + })), + ...Arr.map(step.users ?? [], (userId) => ({ type: "user", user_id: userId })), + ...Arr.map(step.schedules ?? [], (n) => ({ + type: "schedule", + schedule_id: encodePublicId("onc", chainScheduleUuid(n)), + })), + ], wait_minutes: step.wait, })), repeat_count: spec.repeat ?? 0, From 375d9dc2afe912341d66599017cd8d43432ba476 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:16:05 +0100 Subject: [PATCH 31/98] fix(web): the incident rail dates a next page due on another day and keeps the escalation section through a load or failed read next page at shows the date when next_step_at falls on a different local day than now. The rail takes the escalation read as AsyncData: loading and failed reads show a line, only a successful read without a run hides the section. --- .../incident-escalation.scene.test.ts | 39 +++++++++++++++++++ .../web/src/page/alerts/incident/view-rail.ts | 35 ++++++++++++++--- apps/web/src/page/alerts/incident/view.ts | 11 +++--- .../alerts/shared/escalation-chains.test.ts | 11 +++--- .../page/alerts/shared/escalation-chains.ts | 5 ++- packages/web-data/src/alerts/form-utils.ts | 7 ++++ 6 files changed, 89 insertions(+), 19 deletions(-) diff --git a/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts b/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts index 5f0626fc56..409b98e24e 100644 --- a/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts +++ b/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts @@ -4,6 +4,7 @@ import { Result, Schema, Array as Arr } from "effect" import { expect as expectView, given, Mount, role, scene, text, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" +import { toLoadFailure } from "@maple/views/data/load-failure" import type { PageContext } from "../../contract" import { runOf } from "../shared/escalation-test-fixtures" import { incidentRow, INCIDENT_ID, NOW, ORG, ruleRow } from "../shared/test-rows" @@ -67,6 +68,44 @@ describe("alert incident, escalation", () => { ) }) + test("a page due later today reads as a time, one due tomorrow carries its date", () => { + scene( + { update: pageUpdate, view }, + given(withRun(synced(), runOf("active", { nextStepAt: "2026-10-01T23:30:00.000Z" }))), + themeResolved(), + expectView(text(/next page at 11:30\s?PM$/u)).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(withRun(synced(), runOf("active", { nextStepAt: "2026-10-02T09:30:00.000Z" }))), + themeResolved(), + expectView(text(/next page at Oct 2, 2026, 09:30\s?AM$/u)).toExist(), + ) + }) + + test("a failed read keeps the section and says so; a pending one says it is loading", () => { + const failed = pageUpdate( + synced(), + Message.CompletedFetchEscalation({ + key: synced().escalation.key ?? "", + result: Result.fail(toLoadFailure("upstream down")), + }), + ).model + scene( + { update: pageUpdate, view }, + given(failed), + themeResolved(), + expectView(role("heading", { name: "Escalation" })).toExist(), + expectView(text("Couldn't load the escalation.")).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(synced()), + themeResolved(), + expectView(text("Loading the escalation…")).toExist(), + ) + }) + test("an acknowledged incident pauses the chain", () => { scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/alerts/incident/view-rail.ts b/apps/web/src/page/alerts/incident/view-rail.ts index 2410bc0291..b23834ffbe 100644 --- a/apps/web/src/page/alerts/incident/view-rail.ts +++ b/apps/web/src/page/alerts/incident/view-rail.ts @@ -17,7 +17,8 @@ import type { Tone } from "@maple/ui/lib/tone" import { isoMs } from "../shared/time" import { typography, typographyClass } from "@maple/views/ui/typography" import { hstack, vstack } from "@maple/views/ui/stack" -import { Array as Arr } from "effect" +import { Array as Arr, Option } from "effect" +import { AsyncData } from "foldkit" // `IncidentRail`: the incident detail's side rail. @@ -290,8 +291,9 @@ export const escalationSection = ( run: V2AlertIncidentEscalation, incident: AlertIncidentDocument, timeZone: string, + now: number, ): Html => { - const view = runView(run, incident.acknowledgedAt !== null, timeZone) + const view = runView(run, incident.acknowledgedAt !== null, timeZone, now) return railSection( h, ["Escalation"], @@ -319,6 +321,29 @@ export const escalationSection = ( ) } +export type EscalationData = AsyncData.AsyncData + +const escalationNote = (h: HtmlBuilder, text: string): Html => + railSection(h, ["Escalation"], null, [typography(h, { size: "xs", tone: "muted", as: "p" }, [text])]) + +/** The run once read, a note while it loads or after it failed, nothing for an incident without one. */ +const escalationBlock = ( + h: HtmlBuilder, + escalation: EscalationData, + incident: AlertIncidentDocument, + timeZone: string, + now: number, +): ReadonlyArray => + Option.match(AsyncData.getData(escalation), { + onSome: (run) => (run === null ? [] : [escalationSection(h, run, incident, timeZone, now)]), + onNone: () => [ + escalationNote( + h, + AsyncData.isFailure(escalation) ? "Couldn't load the escalation." : "Loading the escalation…", + ), + ], + }) + export const incidentRail = ( h: HtmlBuilder, options: Readonly<{ @@ -328,7 +353,7 @@ export const incidentRail = ( ruleIncidents: ReadonlyArray window: Readonly<{ start: number; end: number }> now: number - escalation: V2AlertIncidentEscalation | null + escalation: EscalationData timeZone: string }>, ): Html => { @@ -337,9 +362,7 @@ export const incidentRail = ( return h.aside( [h.Class("space-y-5 lg:border-l lg:pl-6")], [ - ...(options.escalation === null - ? [] - : [escalationSection(h, options.escalation, incident, options.timeZone)]), + ...escalationBlock(h, options.escalation, incident, options.timeZone, options.now), ...(isGrouped && rule !== null ? [ groupsSection( diff --git a/apps/web/src/page/alerts/incident/view.ts b/apps/web/src/page/alerts/incident/view.ts index 855f7d9596..b9f639115d 100644 --- a/apps/web/src/page/alerts/incident/view.ts +++ b/apps/web/src/page/alerts/incident/view.ts @@ -1,4 +1,3 @@ -import type { V2AlertIncidentEscalation } from "@maple/domain/http/v2" import type { AlertCheckDocument, AlertIncidentDocument, @@ -51,7 +50,7 @@ import { msIso } from "../shared/time" import { groupPreviewOf, type IncidentView, incidentViewOf, ruleStatesOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" -import { incidentRail } from "./view-rail" +import { type EscalationData, incidentRail } from "./view-rail" import { incidentTimeline } from "./view-timeline" import { typography } from "@maple/views/ui/typography" @@ -167,8 +166,8 @@ export type IncidentBodyInputs = Readonly<{ entries: ReadonlyArray isEntriesLoading: boolean isEntriesFailed: boolean - /** The incident's latest escalation run, once read; `null` without one. */ - escalation?: V2AlertIncidentEscalation | null + /** The read of the incident's latest escalation run; a success of `null` means it has none. */ + escalation?: EscalationData /** An SLO or uptime incident's SLO or monitor page; its tab stands in until it is known. */ sourceHref?: string | null chartId: string @@ -265,7 +264,7 @@ export const incidentBody = (h: HtmlBuilder, inputs: IncidentBodyInputs ruleIncidents: inputs.ruleIncidents, window: inputs.range, now: inputs.now, - escalation: inputs.escalation ?? null, + escalation: inputs.escalation ?? AsyncData.succeed(null), timeZone: inputs.timeZone, }), ], @@ -299,7 +298,7 @@ const body = (h: H, model: Model, view: IncidentView, timeZone: string, now: num incident, rule, sourceHref: Option.getOrNull(incident.signalType === "uptime" ? monitorLink : sloLink), - escalation: AsyncData.getOrElse(model.escalation.data, () => null), + escalation: model.escalation.data, ruleIncidents: view.ruleIncidents, states: ruleStatesOf(model, incident.ruleId), preview: groupPreviewOf(previewState.preview, incident), diff --git a/apps/web/src/page/alerts/shared/escalation-chains.test.ts b/apps/web/src/page/alerts/shared/escalation-chains.test.ts index cbaaba121e..100484bab7 100644 --- a/apps/web/src/page/alerts/shared/escalation-chains.test.ts +++ b/apps/web/src/page/alerts/shared/escalation-chains.test.ts @@ -2,6 +2,7 @@ import { Array as Arr, Option } from "effect" import { describe, expect, it } from "vitest" import { chainSummary, formatWait, runView } from "./escalation-chains" import { chainDestinations, chainOf, runOf } from "./escalation-test-fixtures" +import { NOW } from "./test-rows" const nameOf = (id: string) => Option.getOrElse( @@ -57,25 +58,25 @@ describe("formatWait", () => { describe("runView", () => { it("an active run says where it is and when it pages next", () => { - const view = runView(runOf("active", { nextStepAt: "2026-10-01T14:25:00.000Z" }), false, "UTC") + const view = runView(runOf("active", { nextStepAt: "2026-10-01T14:25:00.000Z" }), false, "UTC", NOW) expect(view.state).toBe("escalating") expect(view.detail).toMatch(/^Step 2 of 3, pass 1 of 3, next page at .*2:25/u) }) it("an acknowledged incident reads as paused even before the run itself pauses", () => { - expect(runView(runOf("active"), true, "UTC")).toMatchObject({ + expect(runView(runOf("active"), true, "UTC", NOW)).toMatchObject({ state: "paused", label: "Paused: acknowledged", }) - expect(runView(runOf("acknowledged"), false, "UTC").state).toBe("paused") + expect(runView(runOf("acknowledged"), false, "UTC", NOW).state).toBe("paused") }) it("an ended run says why it ended", () => { - expect(runView(runOf("exhausted"), false, "UTC")).toEqual({ + expect(runView(runOf("exhausted"), false, "UTC", NOW)).toEqual({ state: "finished", label: "Finished", detail: "Every step was sent", }) - expect(runView(runOf("resolved"), true, "UTC").detail).toBe("Ended when the incident resolved") + expect(runView(runOf("resolved"), true, "UTC", NOW).detail).toBe("Ended when the incident resolved") }) }) diff --git a/apps/web/src/page/alerts/shared/escalation-chains.ts b/apps/web/src/page/alerts/shared/escalation-chains.ts index 163455537e..a75ccb1783 100644 --- a/apps/web/src/page/alerts/shared/escalation-chains.ts +++ b/apps/web/src/page/alerts/shared/escalation-chains.ts @@ -4,7 +4,7 @@ import type { V2EscalationChain, V2EscalationChainCreateParams, } from "@maple/domain/http/v2" -import { formatAlertTime } from "@maple/web-data/alerts/form-utils" +import { formatAlertTimeNear } from "@maple/web-data/alerts/form-utils" import { Array as Arr, Effect } from "effect" import { toLoadFailure } from "@maple/views/data/load-failure" import { MapleApiV2Client } from "../../../platform/api-clients" @@ -127,6 +127,7 @@ export const runView = ( run: V2AlertIncidentEscalation, isAcknowledged: boolean, timeZone: string, + nowMs: number, ): RunView => { const position = `Step ${run.step} of ${run.step_count}${run.pass_count > 1 ? `, pass ${run.pass} of ${run.pass_count}` : ""}` if (run.status === "resolved" || run.status === "exhausted" || run.status === "superseded") @@ -143,6 +144,6 @@ export const runView = ( detail: run.next_step_at === null ? position - : `${position}, next page at ${formatAlertTime(run.next_step_at, timeZone)}`, + : `${position}, next page at ${formatAlertTimeNear(run.next_step_at, timeZone, nowMs)}`, } } diff --git a/packages/web-data/src/alerts/form-utils.ts b/packages/web-data/src/alerts/form-utils.ts index 6cbfe78910..e1f23fb22c 100644 --- a/packages/web-data/src/alerts/form-utils.ts +++ b/packages/web-data/src/alerts/form-utils.ts @@ -1133,6 +1133,13 @@ const startOfDay = (ms: number, timeZone: string | undefined): number => { return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() } +/** Time of day when `value` falls on the same local day as `nowMs`, else date and time. */ +export function formatAlertTimeNear(value: string, timeZone: string, nowMs: number): string { + return startOfDay(new Date(value).getTime(), timeZone) === startOfDay(nowMs, timeZone) + ? formatAlertTime(value, timeZone) + : formatAlertDateTime(value, timeZone) +} + /** Day-bucket heading: `Today` / `Yesterday` / `Jun 4, 2026`. */ function formatAlertDayHeading(value: string, timeZone: string | undefined, nowMs: number): string { const date = new Date(value) From 94397381b41d63c6d660612638132a0ef34d8041 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:17:03 +0100 Subject: [PATCH 32/98] fix(web): the incident rail adds no escalation section while the read is pending --- .../alerts/incident/incident-escalation.scene.test.ts | 4 ++-- apps/web/src/page/alerts/incident/view-rail.ts | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts b/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts index 409b98e24e..ae894b7943 100644 --- a/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts +++ b/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts @@ -83,7 +83,7 @@ describe("alert incident, escalation", () => { ) }) - test("a failed read keeps the section and says so; a pending one says it is loading", () => { + test("a failed read keeps the section and says so; a pending one adds nothing yet", () => { const failed = pageUpdate( synced(), Message.CompletedFetchEscalation({ @@ -102,7 +102,7 @@ describe("alert incident, escalation", () => { { update: pageUpdate, view }, given(synced()), themeResolved(), - expectView(text("Loading the escalation…")).toExist(), + expectView(role("heading", { name: "Escalation" })).not.toExist(), ) }) diff --git a/apps/web/src/page/alerts/incident/view-rail.ts b/apps/web/src/page/alerts/incident/view-rail.ts index b23834ffbe..ffe67aa80e 100644 --- a/apps/web/src/page/alerts/incident/view-rail.ts +++ b/apps/web/src/page/alerts/incident/view-rail.ts @@ -336,12 +336,9 @@ const escalationBlock = ( ): ReadonlyArray => Option.match(AsyncData.getData(escalation), { onSome: (run) => (run === null ? [] : [escalationSection(h, run, incident, timeZone, now)]), - onNone: () => [ - escalationNote( - h, - AsyncData.isFailure(escalation) ? "Couldn't load the escalation." : "Loading the escalation…", - ), - ], + // While loading, most incidents have no chain, so the rail stays as it is rather than shift. + onNone: () => + AsyncData.isFailure(escalation) ? [escalationNote(h, "Couldn't load the escalation.")] : [], }) export const incidentRail = ( From b8defc3f41de031a13283f8068979b01f2ce545b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:25:08 +0100 Subject: [PATCH 33/98] chore(mcp): regenerate tool descriptors after merging main --- apps/ai/src/mcp/tool-descriptors.generated.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/ai/src/mcp/tool-descriptors.generated.ts b/apps/ai/src/mcp/tool-descriptors.generated.ts index 1da2b6d3b6..6004aa6db4 100644 --- a/apps/ai/src/mcp/tool-descriptors.generated.ts +++ b/apps/ai/src/mcp/tool-descriptors.generated.ts @@ -27,17 +27,21 @@ export const MCP_TOOL_DESCRIPTORS: ReadonlyArray = [ {"name":"query_data","title":"Query Data","description":"Aggregate traces, logs, metrics or product events into a timeseries or a top-N breakdown. Start here for trends, comparisons and top-N; for errors prefer find_errors and error_detail; for a shape this tool cannot express, run_sql. Applied defaults are listed in the result. A traces breakdown by span_name or attribute reads raw spans: set `service` and keep the window short (a window too large is narrowed, with a note). For endpoint volume over days use route_usage.","inputSchema":{"type":"object","properties":{"source":{"type":"string","enum":["traces","logs","metrics","product_events"],"description":"'traces' for request/span analysis (latency, errors, throughput); 'logs' for log volume; 'metrics' for a custom metric (needs metric_name and metric_type from list_metrics); 'product_events' for track() events, page views and server events (names from list_product_events)."},"kind":{"type":"string","enum":["timeseries","breakdown"],"description":"'timeseries' for how a value changed over time; 'breakdown' for top-N or distribution. Pick the right one first rather than calling twice."},"metric":{"type":"string","description":"Traces: count, avg_duration, p50_duration, p95_duration, p99_duration, error_rate (0-1 ratio), apdex (needs apdex_threshold_ms). Logs: count. Product events: count, sessions, persons, users, visitors. Metrics: avg, sum, min, max, count, rate, increase. For counters (sum metrics, Prometheus *_total gauges) use increase (total over the window) or rate (per second); both are per-series and reset-aware. sum adds raw samples. Default: count, or avg for metrics."},"group_by":{"type":"string","description":"Traces: service, span_name, status_code, http_method, attribute, resource_attribute. Logs: service, severity. Metrics: service, attribute, resource_attribute. Product events: event_name, kind, source, host, page_path, service, group, attribute. 'attribute' and 'resource_attribute' need attribute_key (resource_attribute reads resource keys such as k8s.pod.name or deployment.environment). Timeseries also take 'none' (the default); breakdown defaults to service."},"start_time":{"type":"string","description":"Window start, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601). Default: 6 hours before end_time."},"end_time":{"type":"string","description":"Window end, UTC, same format. Default: now."},"service":{"type":"string","description":"Only this service (exact `service.name`)"},"span_name":{"type":"string","description":"Filter by span name (traces only). Exact match first; when nothing matches exactly it is matched as a case-insensitive substring"},"root_spans_only":{"type":"boolean","description":"Only include root spans (traces only)"},"environments":{"type":"array","items":{"type":"string"},"description":"Only these deployment environments (traces, logs, metrics; explore_attributes source=services lists them)"},"commit_shas":{"type":"array","items":{"type":"string"},"description":"Commit SHAs to filter (traces only)"},"apdex_threshold_ms":{"type":"number","description":"Apdex threshold in ms (traces only; needed for metric=apdex)"},"severity":{"type":"string","enum":["TRACE","DEBUG","INFO","WARN","ERROR","FATAL"],"description":"Only this log severity (logs only)"},"event_name":{"type":"array","items":{"type":"string"},"description":"Only these event names (product_events only)"},"event_kind":{"type":"array","items":{"type":"string","enum":["navigation","custom","screen"]},"description":"Only these event kinds (product_events only)"},"host":{"type":"string","description":"Only events fired on this site host (product_events only)"},"page_path":{"type":"string","description":"Only events fired on this page path (product_events only)"},"metric_name":{"type":"string","description":"The metric, from list_metrics (source=metrics only)"},"metric_type":{"type":"string","enum":["sum","gauge","histogram","exponential_histogram"],"description":"Type of `metric_name`, as list_metrics reports it"},"attribute_key":{"type":"string","description":"Attribute to filter on, or to group by with group_by=attribute. Span (or log/metric label) and resource attributes both work; the scope is resolved automatically"},"attribute_value":{"type":"string","description":"Exact value for attribute_key. Also applies when grouping by the same key"},"attribute_scope":{"type":"string","enum":["span","resource"],"description":"Where attribute_key lives: 'span' (span, log or metric-datapoint attributes) or 'resource' (deployment.environment, k8s.pod.name...). Resolved from the known keys when omitted"},"bucket_seconds":{"type":"number","description":"Bucket width in seconds (timeseries only; auto-computed when omitted)"},"limit":{"type":"number","description":"Max rows of a breakdown (default 10, max 100)"}},"required":["source","kind"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"timeRange":{"type":"object","properties":{"start":{"type":"string"},"end":{"type":"string"}},"required":["start","end"],"additionalProperties":true},"kind":{"type":"string"},"metric":{"type":"string"},"groupBy":{"type":"string"},"decisions":{"type":"array","items":{"type":"string"}},"warnings":{"type":"array","items":{"type":"string"}},"queryContext":{"type":"object","properties":{"source":{"type":"string","enum":["traces","logs","metrics","product_events"]},"serviceName":{"type":"string"},"eventName":{"type":"string"},"eventKind":{"type":"string"},"host":{"type":"string"},"pagePath":{"type":"string"},"spanName":{"type":"string"},"rootSpansOnly":{"type":"boolean"},"environments":{"type":"array","items":{"type":"string"}},"commitShas":{"type":"array","items":{"type":"string"}},"severity":{"type":"string"},"metricName":{"type":"string"},"metricType":{"type":"string"},"attributeFilters":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"value":{"type":"string"},"mode":{"type":"string"}},"required":["key","mode"],"additionalProperties":true}},"apdexThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"bucketSeconds":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["source"],"additionalProperties":true},"unit":{"type":"string","enum":["duration_ms","duration_us","duration_s","duration_ns","percent","number","bytes","requests_per_sec"]},"result":{"anyOf":[{"type":"object","properties":{"kind":{"type":"string","enum":["timeseries"]},"data":{"type":"array","items":{"type":"object","properties":{"bucket":{"type":"string"},"series":{"type":"object","additionalProperties":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}}},"required":["bucket","series"],"additionalProperties":true}}},"required":["kind","data"],"additionalProperties":true},{"type":"object","properties":{"kind":{"type":"string","enum":["breakdown"]},"data":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"value":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["name","value"],"additionalProperties":true}}},"required":["kind","data"],"additionalProperties":true}]},"emptyHints":{"type":"array","items":{"type":"string"}}},"required":["timeRange","kind","metric","queryContext","unit","result"],"additionalProperties":true},"annotations":{"title":"Query Data","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"run_sql","title":"Run SQL","description":"Run one read-only ClickHouse SELECT against the org's warehouse and return its rows: at most 1000 are fetched and the first 100 rendered, with the count reported. Use it for what query_data cannot express, to spot-check data, or to test SQL for a raw_sql widget. For trends and top-N prefer query_data. The SQL must AND in `$__orgFilter`; macros are listed on `sql`. Spans are in `traces` (no otel_spans/spans table), log lines in `logs`; also metrics_sum/metrics_gauge/metrics_histogram, error_events, session_replays, product_events and *_hourly/*_minutely rollups. Select 64-bit IDs and hashes as toString(col). Attribute columns are Map(String, String): SpanAttributes['http.route'], never JSON* functions. In traces the name is SpanName, StatusCode is 'Ok'/'Error' and Duration is nanoseconds. Compare timestamps to 'YYYY-MM-DD HH:MM:SS', not ISO T/Z strings; bucket with toStartOfInterval. Call describe_warehouse_tables for a table's columns, units and enum casing before guessing names.","inputSchema":{"type":"object","properties":{"sql":{"type":"string","description":"The SELECT. It MUST contain `$__orgFilter` (expands to `(OrgId = '')`) as a top-level AND condition; a query without it, or with it OR-ed against another condition, is rejected. In joins use `$__orgFilter(alias)` (`(alias.OrgId = '')`) once per joined table. Other macros: `$__timeFilter(Column)` (`Column >= AND Column <= `), `$__startTime`, `$__endTime`, `$__interval_s` (bucket width for toStartOfInterval). Only one statement; DDL/DML, SETTINGS and system tables are rejected."},"start_time":{"type":"string","description":"Window start, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601). Default: 6 hours before end_time."},"end_time":{"type":"string","description":"Window end, UTC, same format. Default: now."},"granularity_seconds":{"type":"number","description":"Value of `$__interval_s`. Auto-computed from the window when omitted."},"max_cell_chars":{"type":"number","description":"Clip each rendered cell to this many characters (default 120, max 2000). Raise it to read long values such as log bodies or stack traces."},"show_sql":{"type":"boolean","description":"Also return the macro-expanded SQL that ran (default false)."},"reason":{"type":"string","description":"Why you are running this query, in a sentence. Optional; does not change the query."}},"required":["sql"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"rowCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"columns":{"type":"array","items":{"type":"string"}},"rows":{"type":"array","items":{"type":"object"}},"truncated":{"type":"boolean"},"timeRange":{"type":"object","properties":{"start":{"type":"string"},"end":{"type":"string"}},"required":["start","end"],"additionalProperties":true},"maxCellChars":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowApplied":{"type":"boolean"},"expandedSql":{"type":"string"},"atRowCap":{"type":"boolean"},"rewrites":{"type":"array","items":{"type":"object","properties":{"from":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"additionalProperties":true}}},"required":["rowCount","columns","rows","truncated","timeRange"],"additionalProperties":true},"annotations":{"title":"Run SQL","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"service_map","title":"Service Map","description":"Show service-to-service dependencies with call counts, error rates, and latency per edge. Use to understand system architecture and identify problematic inter-service calls.","inputSchema":{"type":"object","properties":{"start_time":{"type":"string","description":"Window start, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601). Default: 6 hours before end_time."},"end_time":{"type":"string","description":"Window end, UTC, same format. Default: now."},"service":{"type":"string","description":"Only edges involving this service, as source or target (exact `service.name`)"},"environment":{"type":"string","description":"Only this deployment environment (e.g. production, staging)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"timeRange":{"type":"object","properties":{"start":{"type":"string"},"end":{"type":"string"}},"required":["start","end"],"additionalProperties":true},"edges":{"type":"array","items":{"type":"object","properties":{"sourceService":{"type":"string"},"targetService":{"type":"string"},"callCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"errorCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"avgDurationMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"maxDurationMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["sourceService","targetService","callCount","errorCount","avgDurationMs","maxDurationMs"],"additionalProperties":true}},"serviceCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"service":{"type":"string"},"environment":{"type":"string"}},"required":["timeRange","edges","serviceCount"],"additionalProperties":true},"annotations":{"title":"Service Map","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, - {"name":"list_alert_rules","title":"List Alert Rules","description":"List configured alert rules with severity, signal type and condition. Use list_alert_incidents for what has fired.","inputSchema":{"type":"object","properties":{"services":{"type":"array","items":{"type":"string"},"description":"Only rules covering any of these services: scoped to them, scoped to every service, or raw_query rules whose SQL or name mentions them"},"signal_type":{"type":"string","enum":["error_rate","p95_latency","p99_latency","apdex","throughput","builder_query","raw_query"],"description":"Only rules on this signal type"},"severity":{"type":"string","enum":["warning","critical"],"description":"Only rules with this severity"},"enabled_only":{"type":"boolean","description":"Only enabled rules"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"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}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"services":{"type":"array","items":{"type":"string"}},"signalType":{"type":"string"},"severity":{"type":"string"},"enabledOnly":{"type":"boolean"}},"required":["rules","total"],"additionalProperties":true},"annotations":{"title":"List Alert Rules","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"list_alert_rules","title":"List Alert Rules","description":"List configured alert rules with severity, signal type and condition. Use list_alert_incidents for what has fired.","inputSchema":{"type":"object","properties":{"services":{"type":"array","items":{"type":"string"},"description":"Only rules covering any of these services: scoped to them, scoped to every service, or raw_query rules whose SQL or name mentions them"},"signal_type":{"type":"string","enum":["error_rate","p95_latency","p99_latency","apdex","throughput","builder_query","raw_query"],"description":"Only rules on this signal type"},"severity":{"type":"string","enum":["warning","critical"],"description":"Only rules with this severity"},"enabled_only":{"type":"boolean","description":"Only enabled rules"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","enabled","severity","serviceNames","environments","signalType","comparator","threshold","windowMinutes","evaluationIntervalMinutes","slowedIntervalMinutes","destinationIds","createdAt","updatedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"services":{"type":"array","items":{"type":"string"}},"signalType":{"type":"string"},"severity":{"type":"string"},"enabledOnly":{"type":"boolean"}},"required":["rules","total"],"additionalProperties":true},"annotations":{"title":"List Alert Rules","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"list_alert_destinations","title":"List Alert Destinations","description":"List the notification destinations a rule can deliver to, with id, type and delivery health. Pass the ids as destination_ids to create_alert_rule / update_alert_rule.","inputSchema":{"type":"object","properties":{"type":{"type":"string","enum":["pagerduty","webhook","discord","telegram","google-chat","microsoft-teams","incident-io","email","chat","linear"],"description":"Only destinations of this type"},"enabled_only":{"type":"boolean","description":"Only enabled destinations"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"destinations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"type":{"type":"string"},"enabled":{"type":"boolean"},"summary":{"type":"string"},"channelLabel":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastTestedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastTestError":{"anyOf":[{"type":"string"},{"type":"null"}]},"disabledReason":{"anyOf":[{"type":"string"},{"type":"null"}]},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","type","enabled","summary","channelLabel","lastTestedAt","lastTestError","disabledReason","createdAt","updatedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"type":{"type":"string"},"enabledOnly":{"type":"boolean"}},"required":["destinations","total"],"additionalProperties":true},"annotations":{"title":"List Alert Destinations","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, - {"name":"get_alert_rule","title":"Get Alert Rule","description":"Get full configuration details of a specific alert rule including thresholds, service filters, evaluation settings, and notification destinations. Use list_alert_rules to find rule IDs.","inputSchema":{"type":"object","properties":{"rule_id":{"type":"string","description":"Alert rule ID: its UUID or its REST id (alrt_\u2026)"}},"required":["rule_id"],"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"},"excludeServiceNames":{"type":"array","items":{"type":"string"}},"groupBy":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]},"minimumSampleCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"noDataBehavior":{"type":"string"},"consecutiveBreachesRequired":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"consecutiveHealthyRequired":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"renotifyIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"apdexThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"queryBuilderDraft":{"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"}]},"spaceAggregation":{"anyOf":[{"type":"string","enum":["sum","avg","min","max","count"]},{"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},{"type":"null"}]},"rawQuerySql":{"anyOf":[{"type":"string"},{"type":"null"}]},"rawQueryReducer":{"anyOf":[{"type":"string"},{"type":"null"}]},"thresholdUpper":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"notificationTitle":{"anyOf":[{"type":"string"},{"type":"null"}]},"notificationBody":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastEvaluationError":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastEvaluationErrorKind":{"anyOf":[{"type":"string"},{"type":"null"}]},"slowedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastEvaluatedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"destinations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]},"enabled":{"type":"boolean"}},"required":["id","name","type","enabled"],"additionalProperties":true}}},"required":["id","name","enabled","severity","serviceNames","environments","signalType","comparator","threshold","windowMinutes","evaluationIntervalMinutes","slowedIntervalMinutes","destinationIds","createdAt","updatedAt","excludeServiceNames","groupBy","minimumSampleCount","noDataBehavior","consecutiveBreachesRequired","consecutiveHealthyRequired","renotifyIntervalMinutes","apdexThresholdMs","queryBuilderDraft","rawQuerySql","rawQueryReducer"],"additionalProperties":true}},"required":["rule"],"additionalProperties":true},"annotations":{"title":"Get Alert Rule","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"get_alert_rule","title":"Get Alert Rule","description":"Get full configuration details of a specific alert rule including thresholds, service filters, evaluation settings, and notification destinations. Use list_alert_rules to find rule IDs.","inputSchema":{"type":"object","properties":{"rule_id":{"type":"string","description":"Alert rule ID: its UUID or its REST id (alrt_\u2026)"}},"required":["rule_id"],"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"},"excludeServiceNames":{"type":"array","items":{"type":"string"}},"groupBy":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]},"minimumSampleCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"noDataBehavior":{"type":"string"},"consecutiveBreachesRequired":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"consecutiveHealthyRequired":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"renotifyIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"apdexThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"queryBuilderDraft":{"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"}]},"spaceAggregation":{"anyOf":[{"type":"string","enum":["sum","avg","min","max","count"]},{"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},{"type":"null"}]},"rawQuerySql":{"anyOf":[{"type":"string"},{"type":"null"}]},"rawQueryReducer":{"anyOf":[{"type":"string"},{"type":"null"}]},"thresholdUpper":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"notificationTitle":{"anyOf":[{"type":"string"},{"type":"null"}]},"notificationBody":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastEvaluationError":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastEvaluationErrorKind":{"anyOf":[{"type":"string"},{"type":"null"}]},"slowedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastEvaluatedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"destinations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]},"enabled":{"type":"boolean"}},"required":["id","name","type","enabled"],"additionalProperties":true}}},"required":["id","name","enabled","severity","serviceNames","environments","signalType","comparator","threshold","windowMinutes","evaluationIntervalMinutes","slowedIntervalMinutes","destinationIds","createdAt","updatedAt","excludeServiceNames","groupBy","minimumSampleCount","noDataBehavior","consecutiveBreachesRequired","consecutiveHealthyRequired","renotifyIntervalMinutes","apdexThresholdMs","queryBuilderDraft","rawQuerySql","rawQueryReducer"],"additionalProperties":true}},"required":["rule"],"additionalProperties":true},"annotations":{"title":"Get Alert Rule","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"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"},"acknowledged":{"type":"boolean","description":"true: open incidents someone acknowledged; false: open incidents nobody has. Implies status=open"},"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"}]},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"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"},"acknowledged":{"type":"boolean"},"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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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":"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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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)."},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of destination_ids, paging step by step until acknowledged (from list_escalation_chains): its UUID or its REST id (esc_\u2026)"},"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"}]},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"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"},"acknowledged":{"type":"boolean"},"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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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":"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). escalation_chain_id adds step-by-step paging on top.","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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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."},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of destination_ids, paging step by step until acknowledged (from list_escalation_chains): its UUID or its REST id (esc_\u2026)"}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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}}"},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of the destinations (from list_escalation_chains), or \"none\" to detach it from every tier. On a rule with warning and critical tiers an id sets the critical tier: its UUID or its REST id (esc_\u2026)"}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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":"acknowledge_alert_incident","title":"Acknowledge Alert Incident","description":"Mark an open alert incident as acknowledged (someone is on it) or, with acknowledged=false, clear the acknowledgement. While acknowledged, reminders pause; an escalation to a higher severity still notifies and clears it. Only open incidents; ids from list_alert_incidents.","inputSchema":{"type":"object","properties":{"incident_id":{"type":"string","description":"Alert incident ID: its UUID or its REST id (inc_\u2026)"},"acknowledged":{"type":"boolean","description":"false to unacknowledge. Default true."}},"required":["incident_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"ruleId":{"type":"string"},"ruleName":{"type":"string"},"status":{"type":"string"},"acknowledged":{"type":"boolean"},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"anyOf":[{"type":"string"},{"type":"null"}]},"changed":{"type":"boolean"}},"required":["id","ruleId","ruleName","status","acknowledged","acknowledgedAt","acknowledgedBy","changed"],"additionalProperties":true},"annotations":{"title":"Acknowledge Alert Incident","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"list_escalation_chains","title":"List Escalation Chains","description":"List escalation chains: ordered paging steps that notify destinations one step at a time until an incident is acknowledged or resolved. Shows each chain's steps, repeats and the rules using it. Attach one to a rule with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chains":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["chains","total"],"additionalProperties":true},"annotations":{"title":"List Escalation Chains","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"create_escalation_chain","title":"Create Escalation Chain","description":"Create an escalation chain: steps that each notify their destinations, then wait for an acknowledgement before the next step. A rule tier that names the chain still notifies its own destinations at once; the chain pages on top until the incident is acknowledged or resolved. Attach it with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"Chain name, e.g. 'Checkout on-call'"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["destination_ids","wait_minutes"],"additionalProperties":false}},"description":"Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":10},{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. At most 10 steps of 10 destinations"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5 (default 0)"},"description":{"type":"string","description":"Free-form notes"}},"required":["name","steps"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Create Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, + {"name":"update_escalation_chain","title":"Update Escalation Chain","description":"Change an escalation chain's name, steps, repeats or description. Pass only what changes; steps replaces every step. Escalations already running finish on the steps they started with. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to change: its UUID or its REST id (esc_\u2026)"},"name":{"type":"string","description":"New name"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["destination_ids","wait_minutes"],"additionalProperties":false}},"description":"Replaces every step. Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":10},{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. At most 10 steps of 10 destinations"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5"},"description":{"type":"string","description":"New notes"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Update Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"delete_escalation_chain","title":"Delete Escalation Chain","description":"Delete an escalation chain. Refused while a rule tier still names it: detach it with update_alert_rule escalation_chain_id=\"none\" first. Escalations already running finish. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to delete: its UUID or its REST id (esc_\u2026)"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"additionalProperties":true},"annotations":{"title":"Delete Escalation Chain","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"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"}]},"spaceAggregation":{"anyOf":[{"type":"string","enum":["sum","avg","min","max","count"]},{"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}}, From 9ffa2991bf26289a43715b57f85aa4838d436def Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:27:44 +0100 Subject: [PATCH 34/98] fix(api): a pre-1970 on-call timestamp is a 400, not a thrown constructor The v2 schedule and override handlers decode the service request instead of constructing it, mapping a schema failure to AlertValidationError. --- apps/api/src/routes/v2/alerts.http.test.ts | 13 ++++ .../src/routes/v2/oncall-schedules.http.ts | 71 ++++++++++++------- 2 files changed, 58 insertions(+), 26 deletions(-) diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index 41d8a96cc9..25b82485ac 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -1695,6 +1695,19 @@ describe("v2 alerts over HTTP", () => { starts_at: "2020-01-03T17:00:00.000Z", ends_at: "2020-01-04T08:00:00.000Z", }), + // Valid ISO timestamps before 1970: not storable instants, so a 400 rather than a 500. + harness.request("POST", "/v2/alerts/oncall_schedules", key.secret, { + ...weeklySchedule, + layers: [{ ...layer, starts_at: "1960-01-01T00:00:00.000Z" }], + }), + harness.request("PATCH", base, key.secret, { + layers: [{ ...layer, starts_at: "1969-12-31T23:59:59.000Z" }], + }), + harness.request("POST", `${base}/overrides`, key.secret, { + user_id: "user_ben", + starts_at: "1960-01-01T00:00:00.000Z", + ends_at: "2030-01-04T08:00:00.000Z", + }), ]) for (const response of badRequests) { expect(response.status, JSON.stringify(response.body)).toBe(400) diff --git a/apps/api/src/routes/v2/oncall-schedules.http.ts b/apps/api/src/routes/v2/oncall-schedules.http.ts index 313268d1e7..25493813d1 100644 --- a/apps/api/src/routes/v2/oncall-schedules.http.ts +++ b/apps/api/src/routes/v2/oncall-schedules.http.ts @@ -1,5 +1,6 @@ import { HttpApiBuilder } from "effect/http-api" import { + AlertValidationError, CurrentTenant, OnCallOverrideCreateRequest, OnCallScheduleCreateRequest, @@ -25,7 +26,7 @@ import { toV2OnCallLayer, v2TimestampMs, } from "@maple/domain/http/v2" -import { Array as Arr, Clock, Effect } from "effect" +import { Array as Arr, Clock, Effect, Schema } from "effect" import { auditDiff } from "@/routes/v2/audit-changes" import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService" import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" @@ -79,6 +80,23 @@ const toV2Shift = (shift: OnCallShiftDocument): V2OnCallShift => ({ const scheduleObject = (id: OnCallScheduleId) => ({ schedule_id: id }) +/** + * The service request, decoded rather than constructed: a wire timestamp before 1970 is valid + * ISO but not a stored epoch, and must be a 400, not a thrown constructor. + */ +const decodeRequest = + (schema: S) => + (input: unknown) => + Schema.decodeUnknownEffect(schema)(input).pipe( + Effect.mapError( + (error) => new AlertValidationError({ message: error.message, details: [error.message] }), + ), + ) + +const decodeScheduleCreate = decodeRequest(OnCallScheduleCreateRequest) +const decodeScheduleUpdate = decodeRequest(OnCallScheduleUpdateRequest) +const decodeOverrideCreate = decodeRequest(OnCallOverrideCreateRequest) + export const HttpV2OnCallSchedulesLive = HttpApiBuilder.group(MapleApiV2, "oncallSchedules", (handlers) => Effect.gen(function* () { const schedules = yield* OnCallSchedulesService @@ -103,18 +121,19 @@ export const HttpV2OnCallSchedulesLive = HttpApiBuilder.group(MapleApiV2, "oncal .handle("create", ({ payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context + const request = yield* decodeScheduleCreate({ + name: payload.name, + ...(payload.description === undefined + ? undefined + : { description: payload.description }), + timeZone: payload.time_zone, + layers: Arr.map(payload.layers, fromV2OnCallLayer), + }) const created = yield* schedules.create( tenant.orgId, tenant.userId, tenant.roles, - new OnCallScheduleCreateRequest({ - name: payload.name, - ...(payload.description === undefined - ? undefined - : { description: payload.description }), - timeZone: payload.time_zone, - layers: Arr.map(payload.layers, fromV2OnCallLayer), - }), + request, ) yield* recordHttpAudit("oncall_schedule.created", { resourceId: created.id, @@ -126,24 +145,23 @@ export const HttpV2OnCallSchedulesLive = HttpApiBuilder.group(MapleApiV2, "oncal .handle("update", ({ params, payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context + const request = yield* decodeScheduleUpdate({ + ...(payload.name === undefined ? undefined : { name: payload.name }), + ...(payload.description === undefined + ? undefined + : { description: payload.description }), + ...(payload.time_zone === undefined ? undefined : { timeZone: payload.time_zone }), + ...(payload.layers === undefined + ? undefined + : { layers: Arr.map(payload.layers, fromV2OnCallLayer) }), + }) const current = yield* schedules.get(tenant.orgId, params.id) const updated = yield* schedules.update( tenant.orgId, tenant.userId, tenant.roles, params.id, - new OnCallScheduleUpdateRequest({ - ...(payload.name === undefined ? undefined : { name: payload.name }), - ...(payload.description === undefined - ? undefined - : { description: payload.description }), - ...(payload.time_zone === undefined - ? undefined - : { timeZone: payload.time_zone }), - ...(payload.layers === undefined - ? undefined - : { layers: Arr.map(payload.layers, fromV2OnCallLayer) }), - }), + request, ) yield* recordHttpAudit("oncall_schedule.updated", { resourceId: updated.id, @@ -217,16 +235,17 @@ export const HttpV2OnCallSchedulesLive = HttpApiBuilder.group(MapleApiV2, "oncal .handle("createOverride", ({ params, payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context + const request = yield* decodeOverrideCreate({ + userId: payload.user_id, + startsAt: v2TimestampMs(payload.starts_at), + endsAt: v2TimestampMs(payload.ends_at), + }) const created = yield* schedules.createOverride( tenant.orgId, tenant.userId, tenant.roles, params.id, - new OnCallOverrideCreateRequest({ - userId: payload.user_id, - startsAt: v2TimestampMs(payload.starts_at), - endsAt: v2TimestampMs(payload.ends_at), - }), + request, ) yield* recordHttpAudit("oncall_override.created", { resourceId: created.id, From eef61862185f4e29d871304e698d1f3f35d9435e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:27:45 +0100 Subject: [PATCH 35/98] fix(chat): connectors declare direct messages, and a rate limit retries whatever the status ChatOutbound.directMessages marks the connectors that can page a person (Slack, Discord). A new rate_limited reason (Discord 40003, repeated 429s) classifies as retryable even when Discord answers 400. --- .../src/services/alerts/ChatAlertPoster.ts | 7 +++++-- .../alerts/delivery/transports/chat.test.ts | 15 +++++++++++---- .../services/integrations/ChatWorkspaceService.ts | 1 + .../src/connectors/discord/outbound.test.ts | 8 ++++++-- .../src/connectors/discord/outbound.ts | 4 +++- .../src/connectors/slack/outbound.ts | 1 + packages/chat-platform/src/outbound.ts | 12 +++++++++--- 7 files changed, 36 insertions(+), 12 deletions(-) diff --git a/packages/backend/src/services/alerts/ChatAlertPoster.ts b/packages/backend/src/services/alerts/ChatAlertPoster.ts index 2e7512b674..24992f6436 100644 --- a/packages/backend/src/services/alerts/ChatAlertPoster.ts +++ b/packages/backend/src/services/alerts/ChatAlertPoster.ts @@ -36,8 +36,8 @@ import type { ChatAlertPosted, EffectTransportDeps } from "./delivery/Transport" /** * What the platform said, as the delivery queue's failure classes. The connector's `reason` is the - * platform's own word where it gave one; otherwise the status decides, as it does for every HTTP - * provider; with neither, the failure is worth retrying. + * platform's own word where it gave one (`rate_limited` always retries); otherwise the status + * decides, as it does for every HTTP provider; with neither, the failure is worth retrying. */ export const chatDeliveryFailure = (error: ChatOutboundError): AlertDeliveryFailure => { const fields = { @@ -53,6 +53,9 @@ export const chatDeliveryFailure = (error: ChatOutboundError): AlertDeliveryFail return new AlertDeliveryTargetMissingError(fields) case "rejected": return new AlertDeliveryRejectedError(fields) + // Asked to slow down: retried even when the status reads like a refusal (Discord's 400). + case "rate_limited": + return new AlertDeliveryError(fields) case undefined: return error.status === undefined ? new AlertDeliveryError(fields) diff --git a/packages/backend/src/services/alerts/delivery/transports/chat.test.ts b/packages/backend/src/services/alerts/delivery/transports/chat.test.ts index 2fef43fb6b..8325a22745 100644 --- a/packages/backend/src/services/alerts/delivery/transports/chat.test.ts +++ b/packages/backend/src/services/alerts/delivery/transports/chat.test.ts @@ -95,10 +95,8 @@ const cardOf = (blocks: ReadonlyArray): ChatAlertBlock => { const TESTCHAT = chatConnectorId("testchat") -const outboundError = (extra: { - readonly status?: number - readonly reason?: "auth" | "not_found" | "rejected" -}) => new ChatOutboundError({ message: "refused", connectorId: TESTCHAT, operation: "post", ...extra }) +const outboundError = (extra: { readonly status?: number; readonly reason?: ChatOutboundError["reason"] }) => + new ChatOutboundError({ message: "refused", connectorId: TESTCHAT, operation: "post", ...extra }) describe("chat alert blocks", () => { it("is the alert card: title, summary, severity and group, chart, links, footer", () => { @@ -212,6 +210,15 @@ describe("chat delivery failures", () => { ) }) + it("retries a rate limit whatever the status, and not a 400 the platform refused", () => { + // Discord answers 400 to both: code 40003 (DMs opened too fast) and 50007 (blocked). + const tooFast = chatDeliveryFailure(outboundError({ reason: "rate_limited", status: 400 })) + assert.strictEqual(tooFast._tag, "@maple/http/errors/AlertDeliveryError") + assert.isTrue(tooFast.error.retryable) + const blocked = chatDeliveryFailure(outboundError({ reason: "rejected", status: 400 })) + assert.isFalse(blocked.error.retryable) + }) + it("falls back to the status, and retries what nobody explained", () => { assert.strictEqual( chatDeliveryFailure(outboundError({ status: 403 }))._tag, diff --git a/packages/backend/src/services/integrations/ChatWorkspaceService.ts b/packages/backend/src/services/integrations/ChatWorkspaceService.ts index 3d3b168cd3..cfd8fc8914 100644 --- a/packages/backend/src/services/integrations/ChatWorkspaceService.ts +++ b/packages/backend/src/services/integrations/ChatWorkspaceService.ts @@ -697,6 +697,7 @@ const make: Effect.Effect< message: `The Maple bot is no longer in this workspace. Reinstall ${name} from Integrations to add it back.`, }) case "rejected": + case "rate_limited": case undefined: return new IntegrationsUpstreamError({ message: error.message, diff --git a/packages/chat-platform/src/connectors/discord/outbound.test.ts b/packages/chat-platform/src/connectors/discord/outbound.test.ts index c19d9f857b..7f510223b7 100644 --- a/packages/chat-platform/src/connectors/discord/outbound.test.ts +++ b/packages/chat-platform/src/connectors/discord/outbound.test.ts @@ -551,14 +551,18 @@ describe("discord direct messages", () => { }), ) - it.effect("leaves opening DMs too fast retryable", () => { + it.effect("marks opening DMs too fast as a rate limit, though Discord answers 400", () => { const http = stub([ { status: 400, body: '{"message":"You are opening direct messages too fast.","code":40003}' }, ]) return Effect.gen(function* () { const transport = yield* discordOutbound.transport const error = yield* Effect.flip(transport.directMessage("guild_1", "user_ben")) - expect([error.operation, error.reason, error.status]).toEqual(["direct_message", undefined, 400]) + expect([error.operation, error.reason, error.status]).toEqual([ + "direct_message", + "rate_limited", + 400, + ]) }).pipe(Effect.provide(http.layer)) }) }) diff --git a/packages/chat-platform/src/connectors/discord/outbound.ts b/packages/chat-platform/src/connectors/discord/outbound.ts index d5eb9ac8d2..64d8cb9168 100644 --- a/packages/chat-platform/src/connectors/discord/outbound.ts +++ b/packages/chat-platform/src/connectors/discord/outbound.ts @@ -163,7 +163,7 @@ const reasonForFailure = ( PERMANENT_ERROR_CODES.has(value) ? "rejected" : RETRYABLE_ERROR_CODES.has(value) - ? undefined + ? "rate_limited" : reasonForStatus(status), }) @@ -270,6 +270,7 @@ class DiscordRateLimited extends Schema.TaggedError()( export const discordOutbound: ChatOutbound = { connectorId: DISCORD_CONNECTOR_ID, limits: { maxMessageChars: MAX_MESSAGE_CHARS, minEditInterval: MIN_EDIT_INTERVAL }, + directMessages: true, requiredConfig: [{ name: BOT_TOKEN_CONFIG, secret: true }], transport: Effect.gen(function* () { const client = yield* HttpClient.HttpClient @@ -394,6 +395,7 @@ export const discordOutbound: ChatOutbound export const slackOutbound: ChatOutbound = { connectorId: SLACK_CONNECTOR_ID, limits: { maxMessageChars: MAX_MESSAGE_CHARS, minEditInterval: MIN_EDIT_INTERVAL }, + directMessages: true, // The bot token is per workspace and arrives under `WORKSPACE_CREDENTIALS`; nothing is // deployment-wide. requiredConfig: [], diff --git a/packages/chat-platform/src/outbound.ts b/packages/chat-platform/src/outbound.ts index 7c03b4bbc6..840f3cb3a3 100644 --- a/packages/chat-platform/src/outbound.ts +++ b/packages/chat-platform/src/outbound.ts @@ -263,6 +263,11 @@ export interface ChatOutbound { * would otherwise spend no write on a message that already says the right thing. */ readonly liveMessages?: boolean + /** + * Whether {@link ChatOutboundTransport.directMessage} can reach a person. Absent means it + * cannot, so a host paging people skips the connector instead of queueing a page that fails. + */ + readonly directMessages?: boolean /** * The deployment-wide configuration `transport` reads out of {@link ConnectorCredentials}. * @@ -293,10 +298,11 @@ export class ChatOutboundError extends Schema.TaggedError()( status: Schema.optionalKey(Schema.Finite), /** * What the platform said was wrong, where it said so: the bot's grant (`auth`), the address - * (`not_found`), or the message itself (`rejected`). Absent for a failure worth retrying — - * a rate limit, an outage, a reply that could not be read. + * (`not_found`), or the message itself (`rejected`). `rate_limited` is a request to slow + * down whatever the status says, so it is retried. Absent for a failure the status decides: + * an outage, a reply that could not be read. */ - reason: Schema.optionalKey(Schema.Literals(["auth", "not_found", "rejected"])), + reason: Schema.optionalKey(Schema.Literals(["auth", "not_found", "rejected", "rate_limited"])), cause: Schema.optionalKey(Schema.Defect()), }, ) {} From 0b0094dbce2e5d2200f4763c4620e0972c21ba3d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:28:05 +0100 Subject: [PATCH 36/98] fix(alerts): member pages try every linked workspace, skip departed members, and stay bounded - A DM tries the org's workspaces on the connector oldest first; only a not_found moves on, and none accepting fails with a fixed message. - Chat rows are keyed by identity; connectors without DMs are not paged. - Step-time recipients are checked against the member directory; a departed on-call member is a gap (log, oncall_gap, recipients_missing). - The incident is re-read after each claim, the drain has a 20s budget, and attempts are counted at claim so a stranded row gives up at the max. - Failures store a fixed message per class, never provider text. --- .../services/alerts/AlertMemberNotifier.ts | 345 ++++++++++------ .../AlertsService.oncall-paging.test.ts | 374 ++++++++++++++++-- .../src/services/alerts/AlertsService.ts | 58 ++- .../services/alerts/alert-escalation-runs.ts | 111 ++++-- packages/db/src/tables/escalation-chains.ts | 3 +- 5 files changed, 713 insertions(+), 178 deletions(-) diff --git a/packages/backend/src/services/alerts/AlertMemberNotifier.ts b/packages/backend/src/services/alerts/AlertMemberNotifier.ts index 06248917f8..f3550d319a 100644 --- a/packages/backend/src/services/alerts/AlertMemberNotifier.ts +++ b/packages/backend/src/services/alerts/AlertMemberNotifier.ts @@ -2,20 +2,30 @@ * Drains `alert_member_notifications`, the pages escalation steps queued for people: email, or a * direct message through the linked chat workspace. The standard lane runs it after the destination drain. */ -import { alertDeliveryRetryDelayMs, canRetryAlertDelivery } from "@maple/alerting-core" +import { + alertDeliveryRetryDelayMs, + canRetryAlertDelivery, + DEFAULT_ALERT_DELIVERY_RETRY_POLICY, +} from "@maple/alerting-core" import { AlertComparator, AlertEventType, AlertPersistenceError, AlertSeverity, AlertSignalType, - ChatWorkspaceId, + ChatConnectorId, type AlertDeliveryFailure, + type ChatWorkspaceId, type OrgId, } from "@maple/domain/http" import * as PG from "@maple-dev/effect-orm/postgres" -import { AlertIncidents, AlertMemberNotifications, type AlertMemberNotificationRow } from "@maple/db/tables" -import { Array as Arr, Context, Effect, Exit, Layer, Option, Random, Schema } from "effect" +import { + AlertIncidents, + AlertMemberNotifications, + ChatWorkspaces, + type AlertMemberNotificationRow, +} from "@maple/db/tables" +import { Array as Arr, Context, Effect, Exit, Layer, Option, Random, Ref, Schema } from "effect" import { Database } from "@maple/backend/platform/DatabaseLive" import { summarizeCause } from "@maple/backend/platform/describe-cause" import { makeDbExecute } from "@maple/backend/platform/db-execute" @@ -29,8 +39,29 @@ import { ChatAlertPoster } from "./ChatAlertPoster" import { buildChatAlertBlocks, type ChatAlertCardInput } from "./delivery/transports/chat" const LEASE_TTL_MS = 30_000 -const DRAIN_LIMIT = 100 +const DRAIN_LIMIT = 50 const CONCURRENCY = 3 +/** Batches stop once this has passed, so the standard lane's minute tick stays bounded. */ +const DRAIN_BUDGET_MS = 20_000 +const MAX_ATTEMPTS = DEFAULT_ALERT_DELIVERY_RETRY_POLICY.maxAttempts + +/* + * What a failed page stores. Fixed per failure class: provider text can carry the address, and + * `error_message` reaches the v2 and MCP timelines. + */ +const FailureMessages = { + directoryNotConfigured: "Member emails cannot be looked up on this deployment", + directoryUnavailable: "The member directory could not be read", + noEmail: "No email address is known for this member", + emailFailed: "The email could not be sent", + unreadable: "The stored page could not be read", + noChatAccount: "The page names no chat account", + noWorkspaceKnowsUser: "No linked chat workspace could reach this member", + chatAuth: "The chat workspace refused Maple's credentials", + chatRejected: "The chat platform refused the direct message", + chatFailed: "The chat platform could not be reached", + gaveUp: "Not sent: every attempt was used up", +} as const /** The stored step payload, read leniently: only what the email and the card show. */ const MemberPayload = Schema.Struct({ @@ -66,7 +97,6 @@ const MemberPayload = Schema.Struct({ ), ), recipient: Schema.Struct({ - workspaceId: Schema.optionalKey(ChatWorkspaceId), externalUserId: Schema.optionalKey(Schema.String), }), }) @@ -77,6 +107,7 @@ const decodeSeverity = Schema.decodeUnknownOption(AlertSeverity) const decodeSignalType = Schema.decodeUnknownOption(AlertSignalType) const decodeComparator = Schema.decodeUnknownOption(AlertComparator) const decodeEventType = Schema.decodeUnknownOption(AlertEventType) +const decodeConnectorId = Schema.decodeUnknownOption(ChatConnectorId) /** What one attempt came to; `retryable` only matters for a failure. */ type Attempt = @@ -149,20 +180,47 @@ export class AlertMemberNotifier extends Context.Service dbExecute((db) => db.run( PG.update(AlertMemberNotifications) - .set({ - status: "sending", - claimedAt: nowMs, - claimExpiresAt: nowMs + LEASE_TTL_MS, - updatedAt: nowMs, - }) - .where(($) => [$.id.eq(row.id), claimable($, nowMs)]) + .set( + row.attemptNumber >= MAX_ATTEMPTS + ? { + status: "failed", + errorMessage: FailureMessages.gaveUp, + claimedAt: null, + claimExpiresAt: null, + updatedAt: nowMs, + } + : { + status: "sending", + attemptNumber: row.attemptNumber + 1, + claimedAt: nowMs, + claimExpiresAt: nowMs + LEASE_TTL_MS, + updatedAt: nowMs, + }, + ) + .where(($) => [ + $.id.eq(row.id), + $.attemptNumber.eq(row.attemptNumber), + claimable($, nowMs), + ]) .returning("id"), ), - ).pipe(Effect.map((rows) => rows.length > 0)) + ).pipe( + Effect.map((rows) => + rows.length === 0 + ? ("lost" as const) + : row.attemptNumber >= MAX_ATTEMPTS + ? ("exhausted" as const) + : ("claimed" as const), + ), + ) /** Only the lease this worker took: a row reclaimed after it expired is not overwritten. */ const settle = ( @@ -185,6 +243,17 @@ export class AlertMemberNotifier extends Context.Service + dbExecute((db) => + db.run( + PG.from(AlertIncidents) + .select("status", "acknowledgedAt") + .where(($) => [$.id.eq(row.incidentId), $.orgId.eq(row.orgId)]) + .limit(1), + ), + ).pipe(Effect.map(Arr.head)) + const sendEmail = ( row: AlertMemberNotificationRow, input: ChatAlertCardInput, @@ -200,52 +269,87 @@ export class AlertMemberNotifier extends Context.Service candidate.userId === row.userId, ) if (Option.isNone(member)) { - return failure("No email address is known for this member", false) + return failure(FailureMessages.noEmail, false) } const content = yield* buildAlertEmailContent(input.context, input.linkUrl) return yield* email.send(member.value.email, content.subject, content.html).pipe( Effect.as({ status: "sent" }), - Effect.catch((error) => - Effect.succeed(failure(`Email failed: ${error.message}`, true)), - ), + Effect.orElseSucceed(() => failure(FailureMessages.emailFailed, true)), ) - }).pipe(Effect.catch((error) => Effect.succeed(failure(error.message, false)))) + }).pipe(Effect.orElseSucceed(() => failure(FailureMessages.emailFailed, false))) + + const chatFailure = (error: AlertDeliveryFailure): Attempt => { + switch (error._tag) { + case "@maple/http/errors/AlertDeliveryAuthError": + return failure(FailureMessages.chatAuth, false) + case "@maple/http/errors/AlertDeliveryRejectedError": + return failure(FailureMessages.chatRejected, false) + case "@maple/http/errors/AlertDeliveryTargetMissingError": + return failure(FailureMessages.noWorkspaceKnowsUser, false) + case "@maple/http/errors/AlertDeliveryError": + return failure(FailureMessages.chatFailed, error.error.retryable) + } + } + /** + * Identities do not record their workspace, so each of the org's workspaces on the + * connector is tried, oldest first; only "no such user" moves on to the next one. + */ const sendDirectMessage = ( row: AlertMemberNotificationRow, payload: MemberPayload, input: ChatAlertCardInput, - ) => { - const { workspaceId, externalUserId } = payload.recipient - if (workspaceId === undefined || externalUserId === undefined) { - return Effect.succeed(failure("The page names no chat account", false)) - } - return chat - .postDirect({ - orgId: row.orgId, - workspaceId, - externalUserId, - blocks: buildChatAlertBlocks(input), - }) - .pipe( - Effect.as({ status: "sent" }), - Effect.catch((error: AlertDeliveryFailure) => - Effect.succeed(failure(error.message, error.error.retryable)), + ) => + Effect.gen(function* () { + const externalUserId = payload.recipient.externalUserId + const connector = decodeConnectorId(row.channel) + if (externalUserId === undefined || Option.isNone(connector)) { + return failure(FailureMessages.noChatAccount, false) + } + const workspaces = yield* dbExecute((db) => + db.run( + PG.from(ChatWorkspaces) + .select("id", "createdAt") + .where(($) => [$.orgId.eq(row.orgId), $.connector.eq(connector.value)]) + .orderBy(["createdAt", "asc"], ["id", "asc"]), ), ) - } + const blocks = buildChatAlertBlocks(input) + const tryFrom = (ids: ReadonlyArray): Effect.Effect => + Arr.match(ids, { + onEmpty: () => + Effect.succeed(failure(FailureMessages.noWorkspaceKnowsUser, false)), + onNonEmpty: ([workspaceId, ...rest]) => + chat + .postDirect({ orgId: row.orgId, workspaceId, externalUserId, blocks }) + .pipe( + Effect.as({ status: "sent" }), + Effect.catch((error: AlertDeliveryFailure) => + error._tag === + "@maple/http/errors/AlertDeliveryTargetMissingError" + ? tryFrom(rest) + : Effect.succeed(chatFailure(error)), + ), + ), + }) + yield* Effect.annotateCurrentSpan("maple.escalation.chat_workspaces", workspaces.length) + return yield* tryFrom(Arr.map(workspaces, (workspace) => workspace.id)) + }).pipe( + Effect.catchTag("@maple/http/errors/AlertPersistenceError", () => + Effect.succeed(failure(FailureMessages.chatFailed, true)), + ), + ) const deliverOne = Effect.fn("AlertMemberNotifier.deliver")(function* ( row: AlertMemberNotificationRow, - incident: { readonly status: string; readonly acknowledgedAt: number | null } | undefined, members: (orgId: OrgId) => Exit.Exit, { readonly _tag: string }>, ) { yield* Effect.annotateCurrentSpan({ @@ -253,22 +357,29 @@ export class AlertMemberNotifier extends Context.Service Effect.annotateCurrentSpan("maple.escalation.recipient_status", status) + const claimedAt = yield* runtime.now + const claimed = yield* claim(row, claimedAt) + if (claimed === "lost") return "lost" as const + if (claimed === "exhausted") { + yield* annotateStatus("failed") + return "failed" as const + } + const attemptNumber = row.attemptNumber + 1 // Same rule as a queued step to a destination: a resolve or ack that won the race wins. + const incident = yield* incidentOf(row) if ( - incident === undefined || - incident.status !== "open" || - incident.acknowledgedAt !== null + Option.isNone(incident) || + incident.value.status !== "open" || + incident.value.acknowledgedAt !== null ) { yield* settle(row, claimedAt, { status: "skipped", errorMessage: - incident !== undefined && incident.status === "open" + Option.isSome(incident) && incident.value.status === "open" ? "Not sent: the incident was acknowledged before this page went out" : "Not sent: the incident resolved before this page went out", }) @@ -281,7 +392,7 @@ export class AlertMemberNotifier extends Context.Service - db.run( - PG.from(AlertMemberNotifications) - .select() - .where(($) => [claimable($, nowMs)]) - .orderBy(["scheduledAt", "asc"]) - .limit(DRAIN_LIMIT), - ), - ) - yield* Effect.annotateCurrentSpan("maple.escalation.member_notifications", rows.length) - if (rows.length === 0) return { processedCount: 0, failureCount: 0 } - const incidentIds = Arr.dedupe(Arr.map(rows, (row) => row.incidentId)) - const incidents = yield* dbExecute((db) => - db.run( - PG.from(AlertIncidents) - .select("id", "orgId", "status", "acknowledgedAt") - .where(($) => [$.id.in_(...incidentIds)]), - ), - ) - const incidentOf = (row: AlertMemberNotificationRow) => - Arr.findFirst( - incidents, - (incident) => incident.id === row.incidentId && incident.orgId === row.orgId, - ) - // One directory read per org with an email to send. - const emailOrgs = Arr.dedupe( - Arr.flatMap(rows, (row) => (row.channel === "email" ? [row.orgId] : [])), - ) - const directoryEntries = yield* Effect.forEach( - emailOrgs, - (orgId) => - Effect.map( - Effect.exit(orgMembers.listMembers(orgId)), - (exit) => [orgId, exit] as const, + const drainBatch = (deadline: number) => + Effect.gen(function* () { + const nowMs = yield* runtime.now + // Across orgs: the scheduler is the system actor. + const rows = yield* dbExecute((db) => + db.run( + PG.from(AlertMemberNotifications) + .select() + .where(($) => [claimable($, nowMs)]) + .orderBy(["scheduledAt", "asc"]) + .limit(DRAIN_LIMIT), ), - { concurrency: CONCURRENCY }, - ) - const directories = new Map(directoryEntries) - const members = (orgId: OrgId) => directories.get(orgId) ?? Exit.succeed([]) - const outcomes = yield* Effect.forEach( - rows, - (row) => - deliverOne(row, Option.getOrUndefined(incidentOf(row)), members).pipe( - Effect.catchCause((cause) => - Effect.logError("Escalation page crashed").pipe( - Effect.annotateLogs({ - notificationId: row.id, - cause: summarizeCause(cause), - }), - Effect.as("failed" as const), + ) + if (rows.length === 0) return { claimedCount: 0, processedCount: 0, failureCount: 0 } + // One directory read per org with an email to send. + const emailOrgs = Arr.dedupe( + Arr.flatMap(rows, (row) => (row.channel === "email" ? [row.orgId] : [])), + ) + const directoryEntries = yield* Effect.forEach( + emailOrgs, + (orgId) => + Effect.map( + Effect.exit(orgMembers.listMembers(orgId)), + (exit) => [orgId, exit] as const, + ), + { concurrency: CONCURRENCY }, + ) + const directories = new Map(directoryEntries) + const members = (orgId: OrgId) => directories.get(orgId) ?? Exit.succeed([]) + const outcomes = yield* Effect.forEach( + rows, + (row) => + Effect.flatMap(runtime.now, (time) => + time >= deadline ? Effect.succeed("lost" as const) : deliverOne(row, members), + ).pipe( + Effect.catchCause((cause) => + Effect.logError("Escalation page crashed").pipe( + Effect.annotateLogs({ + notificationId: row.id, + cause: summarizeCause(cause), + }), + Effect.as("failed" as const), + ), ), ), + { concurrency: CONCURRENCY }, + ) + return { + claimedCount: rows.length, + processedCount: Arr.filter(outcomes, (outcome) => outcome !== "lost").length, + failureCount: Arr.filter(outcomes, (outcome) => outcome === "failed").length, + } + }) + + const drain = Effect.gen(function* () { + const deadline = (yield* runtime.now) + DRAIN_BUDGET_MS + const totals = yield* Ref.make({ processedCount: 0, failureCount: 0 }) + yield* drainBatch(deadline).pipe( + Effect.tap((batch) => + Ref.update(totals, (sum) => ({ + processedCount: sum.processedCount + batch.processedCount, + failureCount: sum.failureCount + batch.failureCount, + })), + ), + Effect.flatMap((batch) => + Effect.map( + runtime.now, + (time) => batch.claimedCount === DRAIN_LIMIT && time < deadline, ), - { concurrency: CONCURRENCY }, + ), + Effect.repeat({ while: (more) => more }), ) - const processedCount = Arr.filter(outcomes, (outcome) => outcome !== "lost").length - const failureCount = Arr.filter(outcomes, (outcome) => outcome === "failed").length + const result = yield* Ref.get(totals) yield* Effect.annotateCurrentSpan({ - "maple.escalation.member_notifications_processed": processedCount, - "maple.escalation.member_notifications_failed": failureCount, + "maple.escalation.member_notifications_processed": result.processedCount, + "maple.escalation.member_notifications_failed": result.failureCount, }) - return { processedCount, failureCount } + return result }).pipe(Effect.withSpan("AlertMemberNotifier.drain")) return { drain } satisfies AlertMemberNotifierApi diff --git a/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts b/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts index 9251b25618..00245c7099 100644 --- a/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts +++ b/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts @@ -17,19 +17,25 @@ import { type OrgId, } from "@maple/domain/http" import { afterEach, assert, describe, it } from "@effect/vitest" -import { Duration, Effect, Layer, Ref, Schema, Array as Arr } from "effect" +import { Duration, Effect, Layer, Option, Ref, Schema, Array as Arr } from "effect" import { TestClock } from "effect/testing" import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@maple/backend/platform/test-pglite" import { ChatConnectorRegistry, type RegisteredChatConnector, } from "@maple/backend/services/integrations/chat-outbound" +import { + AlertEscalationRunId as AlertEscalationRunIdSchema, + ChatIdentityId as ChatIdentityIdSchema, +} from "@maple/domain/primitives" +import { EmailService } from "@maple/backend/platform/EmailService" import { OrgMembersService, type OrgMember } from "@maple/backend/services/org/OrgMembersService" import { AlertsService, type AlertsServiceApi } from "./AlertsService" import { AlertIncidentAckService } from "./AlertIncidentAckService" import { ChatAlertPoster } from "./ChatAlertPoster" import { EscalationChainsService } from "./EscalationChainsService" import { OnCallSchedulesService } from "./OnCallSchedulesService" +import { memberNotificationDedupeKey } from "./alert-escalation-runs" import { adminRoles, asOrgId, @@ -67,6 +73,10 @@ interface FakeChat { readonly opened: Array<{ readonly workspaceId: string; readonly userId: string }> readonly posted: Array<{ readonly target: ChatTarget; readonly blocks: ReadonlyArray }> readonly attempts: Array + /** Workspaces (the platform's ids) that do not know the person: a post there is `not_found`. */ + readonly unknownIn: Set + /** How long each post takes, in test time. */ + readonly latency: Ref.Ref } const unreachable = () => Effect.die("the page reached another transport method") @@ -91,27 +101,42 @@ const fakeSlack = (chat: FakeChat): RegisteredChatConnector => ({ outbound: { connectorId: SLACK, limits: { maxMessageChars: 1000, minEditInterval: Duration.millis(500) }, + directMessages: true, requiredConfig: [], transport: Effect.succeed({ post: (target, blocks) => - Effect.flatMap(Ref.get(chat.mode), (mode) => { - chat.attempts.push(mode) - if (mode === "ok") { - chat.posted.push({ target, blocks }) - return Effect.succeed({ target, messageId: "ts-1" }) - } - return Effect.fail( - new ChatOutboundError({ - message: - mode === "permanent" - ? "Slack refused the call: messages_tab_disabled" - : "Slack answered 503", - connectorId: SLACK, - operation: "post", - ...(mode === "permanent" ? { reason: "not_found" as const } : undefined), - }), - ) - }), + Ref.get(chat.latency).pipe( + Effect.flatMap(TestClock.adjust), + Effect.andThen(Ref.get(chat.mode)), + Effect.flatMap((mode) => { + if (chat.unknownIn.has(target.workspaceId)) { + return Effect.fail( + new ChatOutboundError({ + message: "Slack refused the call: user_not_found", + connectorId: SLACK, + operation: "post", + reason: "not_found", + }), + ) + } + chat.attempts.push(mode) + if (mode === "ok") { + chat.posted.push({ target, blocks }) + return Effect.succeed({ target, messageId: "ts-1" }) + } + return Effect.fail( + new ChatOutboundError({ + message: + mode === "permanent" + ? "Slack refused the call: messages_tab_disabled" + : "Slack answered 503", + connectorId: SLACK, + operation: "post", + ...(mode === "permanent" ? { reason: "not_found" as const } : undefined), + }), + ) + }), + ), directMessage: (workspaceId, userId) => Effect.sync(() => { chat.opened.push({ workspaceId, userId }) @@ -152,23 +177,51 @@ const errorRate = (value: number): ReadonlyArray> => [ }), ] +type SentEmail = { to: string; subject: string; html: string } + +/** Records each email, running `onSend` first (to ack the incident, or let time pass). */ +const hookedEmail = ( + emails: Array, + onSend: Effect.Effect, +): (typeof EmailService)["Service"] => ({ + isConfigured: true, + send: (to, subject, html) => + Effect.andThen( + onSend, + Effect.sync(() => { + emails.push({ to, subject, html }) + }), + ), +}) + const layerFor = ( testDb: TestDb, state: { tracesAggregateRows: ReadonlyArray> }, - emails: Array<{ to: string; subject: string; html: string }>, + emails: Array, chat: FakeChat, + options: { + /** Read live, so a test can remove somebody after the schedule is saved. */ + readonly directory?: Array + readonly email?: (typeof EmailService)["Service"] + readonly deliveryTimeoutMs?: number + } = {}, ) => { - const members = Layer.succeed(OrgMembersService, stubOrgMembersService(MEMBERS)) + const directory = options.directory ?? [...MEMBERS] + const timeoutMs = options.deliveryTimeoutMs + const members = Layer.succeed(OrgMembersService, stubOrgMembersService(directory)) return Layer.mergeAll( makeLayer( testDb, makeWarehouseStub(state), - { fetch: okFetch }, - stubEmailService(emails), + { + fetch: okFetch, + ...(timeoutMs === undefined ? undefined : { deliveryTimeoutMs: () => timeoutMs }), + }, + options.email ?? stubEmailService(emails), ChatAlertPoster.layer.pipe( Layer.provide(Layer.succeed(ChatConnectorRegistry, [fakeSlack(chat)])), ), - MEMBERS, + directory, ), Layer.effect(EscalationChainsService, EscalationChainsService.make).pipe( Layer.provide(testDb.layer), @@ -185,12 +238,17 @@ const layerFor = ( ) } -const makeChat = Effect.map(Ref.make("ok"), (mode): FakeChat => ({ - mode, - opened: [], - posted: [], - attempts: [], -})) +const makeChat = Effect.map( + Effect.all([Ref.make("ok"), Ref.make(Duration.zero)]), + ([mode, latency]): FakeChat => ({ + mode, + latency, + opened: [], + posted: [], + attempts: [], + unknownIn: new Set(), + }), +) const tick = (alerts: AlertsServiceApi, minutes = 1) => Effect.gen(function* () { @@ -284,11 +342,42 @@ const linkBenOnSlack = (testDb: TestDb, orgId: OrgId) => ) }) +/** Ben also linked an account on `connector`, in a workspace linked at `createdAt`. */ +const linkBenOn = ( + testDb: TestDb, + orgId: OrgId, + connector: string, + workspaceId: string, + externalWorkspaceId: string, + createdAt = "2026-01-01T00:00:00Z", +) => + Effect.promise(async () => { + await executeSql( + testDb, + `insert into chat_workspaces (id, org_id, connector, external_workspace_id, name, settings, created_at) + values ($1, $2, $3, $4, 'Other', '{}'::jsonb, $5::timestamptz)`, + [workspaceId, orgId, connector, externalWorkspaceId, createdAt], + ) + if (connector !== "slack") { + await executeSql( + testDb, + `insert into chat_identities (id, org_id, connector, external_user_id, user_id, display_name, created_at) + values (gen_random_uuid()::text, $1, $2, 'X_BEN', $3, 'ben', now())`, + [orgId, connector, ben], + ) + } + }) + /** Step 1 notifies a webhook, waits 10 minutes; step 2 pages the "API primary" on-call. */ const setup = ( orgName: string, testDb: TestDb, - options: { readonly gap?: boolean; readonly override?: boolean } = {}, + options: { + readonly gap?: boolean + readonly override?: boolean + /** Who step 2 pages instead of the schedule. */ + readonly secondStep?: EscalationStep["targets"] + } = {}, ) => Effect.gen(function* () { const alerts = yield* AlertsService @@ -310,7 +399,10 @@ const setup = ( yield* linkBenOnSlack(testDb, orgId) const steps: [EscalationStep, ...Array] = [ { targets: [{ type: "destination", destinationId: team.id }], waitMinutes: 10 }, - { targets: [{ type: "schedule", scheduleId: schedule.id }], waitMinutes: 15 }, + { + targets: options.secondStep ?? [{ type: "schedule", scheduleId: schedule.id }], + waitMinutes: 15, + }, ] const chain = yield* chains.create( orgId, @@ -362,6 +454,8 @@ describe("escalation pages the on-call", () => { return yield* Effect.gen(function* () { const { alerts, orgId } = yield* setup("org_page_ben", testDb) assert.deepStrictEqual(yield* pageRows(testDb, orgId), []) + // Hazel cannot open a direct message, so Ben's Hazel account is not paged at all. + yield* linkBenOn(testDb, orgId, "hazel", "33333333-3333-4333-8333-333333333333", "H_ACME") yield* ticks(alerts, 10) // Ben covers Ana's week today, so step 2 pages him and only him. @@ -487,7 +581,11 @@ describe("escalation pages the on-call", () => { Arr.map(slack, (row) => [row.status, row.attempt_number]), [["failed", 1]], ) - assert.include(slack[0]?.error_message ?? "", "messages_tab_disabled") + // A fixed message: what the platform said stays out of the timeline. + assert.strictEqual( + slack[0]?.error_message, + "No linked chat workspace could reach this member", + ) assert.deepStrictEqual(chat.attempts, ["permanent"]) }).pipe(Effect.provide(layerFor(testDb, state, emails, chat))) }) @@ -506,8 +604,8 @@ describe("escalation pages the on-call", () => { yield* ticks(alerts, 10) const queued = Arr.filter(yield* pageRows(testDb, orgId), (row) => row.channel === "slack") assert.deepStrictEqual( - Arr.map(queued, (row) => [row.status, row.attempt_number]), - [["queued", 2]], + Arr.map(queued, (row) => [row.status, row.attempt_number, row.error_message]), + [["queued", 1, "The chat platform could not be reached"]], ) yield* Ref.set(chat.mode, "ok") yield* ticks(alerts, 2) @@ -520,6 +618,212 @@ describe("escalation pages the on-call", () => { }).pipe(Effect.provide(layerFor(testDb, state, emails, chat))) }) }) + + it.effect("tries each of the org's Slack workspaces until one knows the person", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRate(10) } + const emails: Array = [] + return Effect.gen(function* () { + const chat = yield* makeChat + chat.unknownIn.add("T_OLD") + yield* TestClock.setTime(NOW) + return yield* Effect.gen(function* () { + const { alerts, orgId } = yield* setup("org_page_two_workspaces", testDb) + // Linked before Acme, so it is tried first; Ben is not in it. + yield* linkBenOn( + testDb, + orgId, + "slack", + "55555555-5555-4555-8555-555555555555", + "T_OLD", + "2025-01-01T00:00:00Z", + ) + yield* ticks(alerts, 10) + assert.deepStrictEqual( + Arr.map(chat.opened, (dm) => dm.workspaceId), + ["T_OLD", "T_ACME"], + ) + assert.deepStrictEqual( + Arr.map(chat.posted, (post) => post.target.workspaceId), + ["T_ACME"], + ) + const slack = Arr.filter(yield* pageRows(testDb, orgId), (row) => row.channel === "slack") + assert.deepStrictEqual( + Arr.map(slack, (row) => [row.status, row.attempt_number]), + [["sent", 1]], + ) + }).pipe(Effect.provide(layerFor(testDb, state, emails, chat))) + }) + }) + + it.effect("pages nobody for an on-call member who left the org, and counts it as a gap", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRate(10) } + const emails: Array = [] + const directory = [...MEMBERS] + return Effect.gen(function* () { + const chat = yield* makeChat + yield* TestClock.setTime(NOW) + return yield* Effect.gen(function* () { + const { alerts, orgId } = yield* setup("org_page_departed", testDb) + // Ben covers today, then leaves before step 2. + const leaving = Arr.findFirstIndex(directory, (member) => member.userId === ben) + if (Option.isSome(leaving)) directory.splice(leaving.value, 1) + yield* ticks(alerts, 10) + assert.deepStrictEqual(yield* pageRows(testDb, orgId), []) + assert.lengthOf(chat.posted, 0) + assert.deepStrictEqual(yield* runStatus(testDb, orgId), { + status: "exhausted", + steps_sent: 2, + }) + }).pipe(Effect.provide(layerFor(testDb, state, emails, chat, { directory }))) + }) + }) + + it.effect("skips the pages left in a drain once one of them sees the incident acknowledged", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRate(10) } + const emails: Array = [] + const ack = Effect.promise(() => + executeSql( + testDb, + `update alert_incidents set acknowledged_at = now(), acknowledged_by_name = 'Ana', + acknowledged_via = 'web' where org_id = 'org_page_ack_mid_drain'`, + ), + ) + return Effect.gen(function* () { + const chat = yield* makeChat + yield* TestClock.setTime(NOW) + return yield* Effect.gen(function* () { + // Three emails and Ben's DM: more pages than the drain sends at once. + const { alerts, orgId } = yield* setup("org_page_ack_mid_drain", testDb, { + secondStep: [ + { type: "user", userId: ana }, + { type: "user", userId: ben }, + { type: "user", userId: chen }, + ], + }) + yield* ticks(alerts, 10) + const statuses = Arr.map(yield* pageRows(testDb, orgId), (row) => row.status) + assert.lengthOf(statuses, 4) + // The first email acks; a page claimed after it is not sent. + assert.include(statuses, "skipped") + assert.isAtMost(emails.length + chat.posted.length, 3) + }).pipe( + Effect.provide(layerFor(testDb, state, emails, chat, { email: hookedEmail(emails, ack) })), + ) + }) + }) + + it.effect("stops a drain at its time budget and sends the rest on later ticks", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRate(10) } + const emails: Array = [] + // Each page takes half a minute. + const slow = TestClock.adjust(Duration.seconds(30)) + return Effect.gen(function* () { + const chat = yield* makeChat + yield* Ref.set(chat.latency, Duration.seconds(30)) + yield* TestClock.setTime(NOW) + return yield* Effect.gen(function* () { + const { alerts, orgId } = yield* setup("org_page_budget", testDb, { + secondStep: [ + { type: "user", userId: ana }, + { type: "user", userId: ben }, + { type: "user", userId: chen }, + ], + }) + yield* ticks(alerts, 10) + const first = Arr.map(yield* pageRows(testDb, orgId), (row) => row.status) + // Pages that started before the budget ran out went; the rest wait, still queued. + assert.include(first, "sent") + assert.include(first, "queued") + yield* ticks(alerts, 3) + assert.deepStrictEqual( + Arr.map(yield* pageRows(testDb, orgId), (row) => row.status), + ["sent", "sent", "sent", "sent"], + ) + }).pipe( + Effect.provide( + layerFor(testDb, state, emails, chat, { + email: hookedEmail(emails, slow), + deliveryTimeoutMs: 60_000, + }), + ), + ) + }) + }) + + it.effect("counts an attempt at claim, so a page stranded mid-send is retried and then given up", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRate(10) } + const emails: Array = [] + return Effect.gen(function* () { + const chat = yield* makeChat + yield* TestClock.setTime(NOW) + return yield* Effect.gen(function* () { + const { alerts, orgId } = yield* setup("org_page_stranded", testDb) + yield* ticks(alerts, 10) + emails.length = 0 + // Two email pages a crashed worker left mid-send: one with attempts left, one without. + yield* Effect.promise(() => + executeSql( + testDb, + `insert into alert_member_notifications (id, org_id, incident_id, run_id, user_id, channel, + status, attempt_number, scheduled_at, claimed_at, claim_expires_at, payload_json, + dedupe_key, created_at, updated_at) + select gen_random_uuid()::text, org_id, incident_id, run_id, n.user_id, channel, 'sending', + n.attempts, scheduled_at, scheduled_at, scheduled_at, payload_json, + dedupe_key || ':' || n.user_id, created_at, updated_at + from alert_member_notifications, + (values ($2, 2), ($3, 5)) as n(user_id, attempts) + where org_id = $1 and channel = 'email'`, + [orgId, ana, chen], + ), + ) + yield* tick(alerts) + const stranded = Arr.filter( + yield* pageRows(testDb, orgId), + (row) => row.user_id === ana || row.user_id === chen, + ) + assert.sameDeepMembers( + Arr.map(stranded, (row) => [ + row.user_id, + row.status, + row.attempt_number, + row.error_message, + ]), + [ + [ana, "sent", 3, null], + [chen, "failed", 5, "Not sent: every attempt was used up"], + ], + ) + assert.deepStrictEqual( + Arr.map(emails, (email) => email.to), + ["ana@acme.test"], + ) + }).pipe(Effect.provide(layerFor(testDb, state, emails, chat))) + }) + }) + + it("keys a chat page by the identity, so two accounts on one connector are two pages", () => { + const runId = Schema.decodeSync(AlertEscalationRunIdSchema)("66666666-6666-4666-8666-666666666666") + const page = (identityId: string) => + memberNotificationDedupeKey(runId, 1, 2, { + userId: ben, + channel: SLACK, + identityId: Schema.decodeSync(ChatIdentityIdSchema)(identityId), + externalUserId: "U_BEN", + }) + assert.notStrictEqual( + page("77777777-7777-4777-8777-777777777777"), + page("88888888-8888-4888-8888-888888888888"), + ) + assert.notStrictEqual( + page("77777777-7777-4777-8777-777777777777"), + memberNotificationDedupeKey(runId, 1, 2, { userId: ben, channel: "email" }), + ) + }) }) describe("escalation chain targets", () => { diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index 9ba432e0c5..0dd334a69a 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -149,6 +149,7 @@ import { enabledDestinationIds, loadStepRecipients, stepDestinationIds, + withoutDepartedMembers, type EscalationWorkRow, endEscalationRunsAbove, loadEscalationChain, @@ -163,6 +164,8 @@ import { makeIncidentPushBudget, type IncidentPushBudget } from "./alert-push-bu import { EmailService } from "@maple/backend/platform/EmailService" import { Env } from "@maple/backend/platform/Env" import { OrgClickHouseSettingsService } from "@maple/backend/services/org/OrgClickHouseSettingsService" +import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" +import { ChatConnectorRegistry } from "@maple/backend/services/integrations/chat-outbound" import { makeDbExecute } from "@maple/backend/platform/db-execute" import { makePersistenceError } from "./alert-persistence" import { QueryEngineService } from "@maple/backend/services/warehouse/QueryEngineService" @@ -659,6 +662,14 @@ export class AlertsService extends Context.Service + connector.outbound.directMessages === true ? [connector.id] : [], + ), + ) const mobilePush = yield* MobilePushService const encryptionKey = yield* parseAlertDestinationEncryptionKey( Redacted.value(env.MAPLE_INGEST_KEY_ENCRYPTION_KEY), @@ -5520,6 +5531,27 @@ export class AlertsService extends Context.Service + orgMembers.listMembers(orgId).pipe( + Effect.map( + (members): ReadonlySet | null => + new Set(Arr.map(members, (member) => member.userId)), + ), + Effect.catchTags({ + "@maple/http/errors/AlertMemberDirectoryNotConfiguredError": () => + Effect.succeed(null), + "@maple/http/errors/AlertMemberDirectoryUnavailableError": () => + Effect.as( + Effect.logWarning("Member directory unavailable at an escalation step"), + null, + ), + }), + ) + /** One run: plan from the row, then a conditional write that only one tick can win. */ const advanceEscalationRun = Effect.fn("AlertsService.advanceEscalationRun")(function* ( row: EscalationWorkRow, @@ -5540,10 +5572,31 @@ export class AlertsService extends Context.Service() // Who is on call is read now, at step time, not when the chain was saved. - const paged = + const resolved = plan.kind === "send" - ? yield* dbExecute((db) => loadStepRecipients(db, row.orgId, plan.step, nowMs)) + ? yield* dbExecute((db) => + loadStepRecipients(db, row.orgId, plan.step, nowMs, directMessageConnectors), + ) : null + const paged = + resolved === null || resolved.userIds.length === 0 + ? resolved + : yield* Effect.map(currentMemberIds(row.orgId), (members) => + withoutDepartedMembers(resolved, members), + ) + if (paged !== null && paged.missingUserIds.length > 0) { + yield* Effect.annotateCurrentSpan( + "maple.escalation.recipients_missing", + paged.missingUserIds.length, + ) + yield* Effect.logWarning("Escalation step targets people who left the organization").pipe( + Effect.annotateLogs({ + runId: row.id, + incidentId: row.incidentId, + userIds: Arr.join(paged.missingUserIds, ","), + }), + ) + } if (paged !== null && paged.gapScheduleIds.length > 0) { yield* Effect.annotateCurrentSpan("maple.escalation.oncall_gap", true) yield* Effect.logWarning("Escalation step targets a schedule with nobody on call").pipe( @@ -5687,6 +5740,7 @@ export class AlertsService extends Context.Service new Set(Arr.map(rows, (row) => row.id)))) -/** One channel a person is paged on: email, or a direct message through a linked chat account. */ +/** One channel a person is paged on: email, or a direct message to a linked chat account. */ export type MemberRecipient = | { readonly userId: UserId; readonly channel: "email" } | { readonly userId: UserId readonly channel: ChatConnectorId - /** The `chat_workspaces` row the message goes out through. */ - readonly workspaceId: ChatWorkspaceId + /** The `chat_identities` row; the workspace is chosen at delivery. */ + readonly identityId: ChatIdentityId readonly externalUserId: string } @@ -280,13 +280,24 @@ export interface StepRecipients { readonly userIds: ReadonlyArray /** Schedule targets with nobody on call at `nowMs` (or deleted since). */ readonly gapScheduleIds: ReadonlyArray + /** Each schedule target's on-call user at `nowMs`. */ + readonly onCall: ReadonlyArray<{ readonly scheduleId: OnCallScheduleId; readonly userId: UserId }> + /** Resolved users dropped because they are no longer org members. */ + readonly missingUserIds: ReadonlyArray } /** * Who a step pages at `nowMs`: its user targets, plus whoever each schedule target has on call. - * Each person gets an email row and one row per chat account they linked in the org. + * Each person gets an email row and one row per chat account they linked in the org, on a + * connector in `directMessageConnectors` with a workspace linked in the org. */ -export const loadStepRecipients = (db: MapleDb, orgId: OrgId, step: EscalationStep, nowMs: number) => +export const loadStepRecipients = ( + db: MapleDb, + orgId: OrgId, + step: EscalationStep, + nowMs: number, + directMessageConnectors: ReadonlySet, +) => Effect.gen(function* () { const directUserIds = Arr.flatMap(step.targets, (target) => target.type === "user" ? [target.userId] : [], @@ -315,7 +326,7 @@ export const loadStepRecipients = (db: MapleDb, orgId: OrgId, step: EscalationSt ]), ), ]) - const onCall = Arr.map(scheduleIds, (scheduleId) => { + const shifts = Arr.map(scheduleIds, (scheduleId) => { const schedule = Arr.findFirst(schedules, (row) => row.id === scheduleId) if (Option.isNone(schedule)) return { scheduleId, userId: null } const shift = onCallAt( @@ -325,48 +336,84 @@ export const loadStepRecipients = (db: MapleDb, orgId: OrgId, step: EscalationSt ) return { scheduleId, userId: shift === null ? null : shift.userId } }) - const userIds = Arr.dedupe([ - ...directUserIds, - ...Arr.flatMap(onCall, (entry) => (entry.userId === null ? [] : [entry.userId])), - ]) - const gapScheduleIds = Arr.flatMap(onCall, (entry) => + const onCall = Arr.flatMap(shifts, ({ scheduleId, userId }) => + userId === null ? [] : [{ scheduleId, userId }], + ) + const userIds = Arr.dedupe([...directUserIds, ...Arr.map(onCall, (entry) => entry.userId)]) + const gapScheduleIds = Arr.flatMap(shifts, (entry) => entry.userId === null ? [entry.scheduleId] : [], ) - if (userIds.length === 0) return { recipients: [], userIds, gapScheduleIds } satisfies StepRecipients + const base = { userIds, gapScheduleIds, onCall, missingUserIds: [] } + if (userIds.length === 0) return { ...base, recipients: [] } satisfies StepRecipients const [identities, workspaces] = yield* Effect.all([ db.run( PG.from(ChatIdentities) - .select("userId", "connector", "externalUserId") - .where(($) => [$.orgId.eq(orgId), $.userId.in_(...userIds)]), + .select("id", "userId", "connector", "externalUserId", "createdAt") + .where(($) => [$.orgId.eq(orgId), $.userId.in_(...userIds)]) + .orderBy(["createdAt", "asc"], ["id", "asc"]), ), db.run( PG.from(ChatWorkspaces) - .select("id", "connector", "createdAt") - .where(($) => [$.orgId.eq(orgId)]) - .orderBy(["createdAt", "asc"]), + .select("connector") + .where(($) => [$.orgId.eq(orgId)]), ), ]) - // A linked account is reached through the org's first workspace on its connector. - const chatRecipients = Arr.flatMap(identities, (identity): ReadonlyArray => { - const workspace = Arr.findFirst(workspaces, (row) => row.connector === identity.connector) - return Option.isNone(workspace) - ? [] - : [ + const linked = new Set(Arr.map(workspaces, (row) => row.connector)) + // A connector that cannot open a DM, or with no workspace to send through, pages nobody. + const chatRecipients = Arr.flatMap(identities, (identity): ReadonlyArray => + directMessageConnectors.has(identity.connector) && linked.has(identity.connector) + ? [ { userId: identity.userId, channel: identity.connector, - workspaceId: workspace.value.id, + identityId: identity.id, externalUserId: identity.externalUserId, }, ] - }) + : [], + ) const recipients = Arr.flatMap(userIds, (userId): ReadonlyArray => [ { userId, channel: "email" }, ...Arr.filter(chatRecipients, (recipient) => recipient.userId === userId), ]) - return { recipients, userIds, gapScheduleIds } satisfies StepRecipients + return { ...base, recipients } satisfies StepRecipients }) +/** + * Drops users the member directory no longer lists; a schedule whose on-call person left counts + * as a gap. `members` null (no directory, or it could not be read) keeps everyone. + */ +export const withoutDepartedMembers = ( + paged: StepRecipients, + members: ReadonlySet | null, +): StepRecipients => { + if (members === null) return paged + const isMember = (userId: UserId) => members.has(userId) + const departed = Arr.filter(paged.userIds, (userId) => !isMember(userId)) + if (departed.length === 0) return paged + return { + recipients: Arr.filter(paged.recipients, (recipient) => isMember(recipient.userId)), + userIds: Arr.filter(paged.userIds, isMember), + gapScheduleIds: [ + ...paged.gapScheduleIds, + ...Arr.flatMap(paged.onCall, (entry) => (isMember(entry.userId) ? [] : [entry.scheduleId])), + ], + onCall: Arr.filter(paged.onCall, (entry) => isMember(entry.userId)), + missingUserIds: departed, + } +} + +/** One row per person, channel and step; a chat row is also keyed by its identity. */ +export const memberNotificationDedupeKey = ( + runId: AlertEscalationRunId, + pass: number, + step: number, + recipient: MemberRecipient, +) => + "identityId" in recipient + ? `${runId}:${pass}:${step}:${recipient.userId}:${recipient.channel}:${recipient.identityId}` + : `${runId}:${pass}:${step}:${recipient.userId}:email` + export type EscalationApplyOutcome = "sent" | "ended" | "paused" | "resumed" | "deferred" | "lost" | "idle" type RunUpdate = Partial> @@ -517,12 +564,18 @@ export const applyEscalationPlan = (db: MapleDb, input: ApplyEscalationPlanInput userId: recipient.userId, channel: recipient.channel, status: "queued", - attemptNumber: 1, + // Attempts started: each claim counts one before it sends. + attemptNumber: 0, scheduledAt: nowMs, claimedAt: null, claimExpiresAt: null, payloadJson: { ...payload, recipient }, - dedupeKey: `${row.id}:${plan.passIndex + 1}:${plan.stepIndex + 1}:${recipient.userId}:${recipient.channel}`, + dedupeKey: memberNotificationDedupeKey( + row.id, + plan.passIndex + 1, + plan.stepIndex + 1, + recipient, + ), errorMessage: null, attemptedAt: null, createdAt: nowMs, diff --git a/packages/db/src/tables/escalation-chains.ts b/packages/db/src/tables/escalation-chains.ts index 3ba078082b..26656830f8 100644 --- a/packages/db/src/tables/escalation-chains.ts +++ b/packages/db/src/tables/escalation-chains.ts @@ -113,6 +113,7 @@ export const AlertMemberNotifications = PG.table("alert_member_notifications", { /** `email`, or the chat connector a direct message goes through. */ channel: PG.text, status: PG.brand(PG.text, AlertIncidentPageStatus), + /** Attempts started; a claim counts one before it sends. */ attemptNumber: PG.column(PG.int4, { name: "attempt_number", default: 1 }), scheduledAt: PG.column(PG.timestamptzMillis, { name: "scheduled_at" }), claimedAt: PG.column(PG.nullable(PG.timestamptzMillis), { name: "claimed_at" }), @@ -121,7 +122,7 @@ export const AlertMemberNotifications = PG.table("alert_member_notifications", { payloadJson: PG.column(PG.jsonb(Schema.Record(Schema.String, Schema.Unknown)), { name: "payload_json", }), - /** `{runId}:{pass}:{step}:{userId}:{channel}`: one page per person, channel and step. */ + /** `{runId}:{pass}:{step}:{userId}:{channel}`, plus `:{identityId}` for chat: one page per account and step. */ dedupeKey: PG.column(PG.text, { name: "dedupe_key" }), errorMessage: PG.column(PG.nullable(PG.text), { name: "error_message" }), attemptedAt: PG.column(PG.nullable(PG.timestamptzMillis), { name: "attempted_at" }), From 1af08d90a2922fcde5883a1fbcc0456c69475229 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:28:05 +0100 Subject: [PATCH 37/98] fix(alerts): schedules without a member directory, names without emails, who_is_on_call at the clamp - requireMembers skips the check where no member directory is configured. - list reads the overrides active now on their own, past the org-wide cap. - memberNames maps names only; an unnamed member shows as their id. - resolveSchedule names its caller's tool in errors. - who_is_on_call reports no next shift when the current one is clamped at the lookaround (ONCALL_LOOKAROUND_MS now lives in the domain). --- apps/ai/src/mcp/lib/oncall-schedules.ts | 8 +- .../__tests__/oncall-schedule-tools.test.ts | 72 ++++++++++++++---- apps/ai/src/mcp/tools/who-is-on-call.ts | 31 ++++++-- packages/alerting-core/src/oncall.ts | 3 +- .../alerts/OnCallSchedulesService.test.ts | 73 ++++++++++++++++++- .../services/alerts/OnCallSchedulesService.ts | 33 ++++++--- packages/domain/src/http/oncall-schedules.ts | 2 + 7 files changed, 182 insertions(+), 40 deletions(-) diff --git a/apps/ai/src/mcp/lib/oncall-schedules.ts b/apps/ai/src/mcp/lib/oncall-schedules.ts index ef7992829e..1a2c7db9cd 100644 --- a/apps/ai/src/mcp/lib/oncall-schedules.ts +++ b/apps/ai/src/mcp/lib/oncall-schedules.ts @@ -233,8 +233,8 @@ export const deleteOverrideErrors = { const isScheduleId = Schema.is(OnCallScheduleId) -/** A schedule by its UUID, its REST id or its name (case-insensitive). */ -export const resolveSchedule = (orgId: OrgId, raw: string, parameter: string) => +/** A schedule by its UUID, its REST id or its name (case-insensitive); errors name `toolName`. */ +export const resolveSchedule = (orgId: OrgId, raw: string, parameter: string, toolName: string) => Effect.gen(function* () { const schedules = yield* OnCallSchedulesService const id = Option.filter(P.internalIdOf("oncallSchedule", raw), isScheduleId) @@ -243,11 +243,11 @@ export const resolveSchedule = (orgId: OrgId, raw: string, parameter: string) => Effect.catchTags({ "@maple/http/errors/OnCallScheduleNotFoundError": () => Effect.fail(scheduleNotFound(raw, parameter)), - "@maple/http/errors/AlertPersistenceError": failWith("who_is_on_call"), + "@maple/http/errors/AlertPersistenceError": failWith(toolName), }), ) } - const listed = yield* schedules.list(orgId).pipe(Effect.mapError(toMcpHttpError("who_is_on_call"))) + const listed = yield* schedules.list(orgId).pipe(Effect.mapError(toMcpHttpError(toolName))) const wanted = Str.toLowerCase(Str.trim(raw)) return yield* Effect.fromOption( Arr.findFirst(listed.schedules, (s) => Str.toLowerCase(s.name) === wanted), diff --git a/apps/ai/src/mcp/tools/__tests__/oncall-schedule-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/oncall-schedule-tools.test.ts index 81c77713a2..d739346036 100644 --- a/apps/ai/src/mcp/tools/__tests__/oncall-schedule-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/oncall-schedule-tools.test.ts @@ -17,6 +17,7 @@ import { EscalationChainId, OnCallSchedulesListResponse, OnCallShiftsResponse, + ONCALL_LOOKAROUND_MS, OrgId, UserId, type OnCallOverrideCreateRequest, @@ -76,6 +77,8 @@ interface Seen { rejectMember?: boolean /** An escalation chain still pages the schedule. */ inUse?: boolean + /** Ana's shift runs past the 15-day lookaround, so its end is the clamp, not a handoff. */ + longShift?: boolean } const rejectMember = () => { @@ -130,6 +133,19 @@ const layer = (seen: Seen) => }, onCallAt: (_org, _id, atMs) => { seen.onCallAt = atMs + if (seen.longShift === true) { + return Effect.succeed( + Schema.decodeSync(OnCallAtResponse)({ + at: new Date(atMs).toISOString(), + shift: { + userId: "user_ana", + startsAt: new Date(atMs - ONCALL_LOOKAROUND_MS).toISOString(), + endsAt: new Date(atMs + ONCALL_LOOKAROUND_MS).toISOString(), + source: { kind: "layer", layerIndex: 0 }, + }, + }), + ) + } return Effect.succeed( Schema.decodeSync(OnCallAtResponse)({ at: new Date(atMs).toISOString(), @@ -142,22 +158,39 @@ const layer = (seen: Seen) => }), ) }, - shifts: () => - Effect.succeed( - Schema.decodeSync(OnCallShiftsResponse)({ - from: "2026-11-13T08:00:00.000Z", - to: "2026-11-27T08:00:00.000Z", - layers: [], - final: [ - { - userId: "user_ana", - startsAt: "2026-11-13T08:00:00.000Z", - endsAt: "2026-11-16T08:00:00.000Z", - source: { kind: "layer", layerIndex: 0 }, - }, - ], - }), - ), + shifts: (_org, _id, fromMs, toMs) => + seen.longShift === true + ? // Rendered from the clamp edge: the same shift, carrying on. + Effect.succeed( + Schema.decodeSync(OnCallShiftsResponse)({ + from: new Date(fromMs).toISOString(), + to: new Date(toMs).toISOString(), + layers: [], + final: [ + { + userId: "user_ana", + startsAt: new Date(fromMs).toISOString(), + endsAt: new Date(toMs).toISOString(), + source: { kind: "layer", layerIndex: 0 }, + }, + ], + }), + ) + : Effect.succeed( + Schema.decodeSync(OnCallShiftsResponse)({ + from: "2026-11-13T08:00:00.000Z", + to: "2026-11-27T08:00:00.000Z", + layers: [], + final: [ + { + userId: "user_ana", + startsAt: "2026-11-13T08:00:00.000Z", + endsAt: "2026-11-16T08:00:00.000Z", + source: { kind: "layer", layerIndex: 0 }, + }, + ], + }), + ), createOverride: (_org, _user, _roles, id, request) => { seen.overrideCreated = request if (seen.rejectMember === true) return rejectMember() @@ -240,6 +273,13 @@ describe("on-call schedule tools", () => { expect(text(result)).toContain("Ana Alves (user_ana) from 2026-11-13T08:00:00.000Z") }) + it("who_is_on_call reports no next shift when the current one runs past the lookaround", async () => { + const result = await ok("who_is_on_call", { schedule: SCHEDULE_ID }, { longShift: true }) + const output = Schema.decodeUnknownSync(WhoIsOnCallOutput)(result.structuredContent) + expect([output.onCall?.user.userId, output.next]).toEqual(["user_ana", null]) + expect(text(result)).toContain("no handoff within two weeks") + }) + it("who_is_on_call takes a REST id and refuses an unknown schedule on the parameter", async () => { await ok("who_is_on_call", { schedule: encodePublicId("onc", SCHEDULE_ID) }) const byId = await run("who_is_on_call", { schedule: MISSING_ID }, {}) diff --git a/apps/ai/src/mcp/tools/who-is-on-call.ts b/apps/ai/src/mcp/tools/who-is-on-call.ts index 09202319a8..e71ed567e0 100644 --- a/apps/ai/src/mcp/tools/who-is-on-call.ts +++ b/apps/ai/src/mcp/tools/who-is-on-call.ts @@ -1,6 +1,7 @@ import type { McpToolRegistrar } from "./types" import { Array as Arr, Clock, Effect, Option, Schema } from "effect" import { parseWarehouseDateTime } from "@maple/query-engine" +import { ONCALL_LOOKAROUND_MS } from "@maple/domain/http" import { WhoIsOnCallOutput } from "@maple/domain/mcp-outputs" import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" import { CurrentMcpTenant } from "../lib/query-warehouse" @@ -29,18 +30,30 @@ export function registerWhoIsOnCallTool(server: McpToolRegistrar) { handler: Effect.fn("McpTool.whoIsOnCall")(function* (params) { const tenant = yield* CurrentMcpTenant const schedules = yield* OnCallSchedulesService - const schedule = yield* resolveSchedule(tenant.orgId, params.schedule, "schedule") + const schedule = yield* resolveSchedule( + tenant.orgId, + params.schedule, + "schedule", + "who_is_on_call", + ) const atMs = params.at === undefined ? yield* Clock.currentTimeMillis : parseWarehouseDateTime(params.at) const toError = Effect.mapError(toMcpHttpError("who_is_on_call")) const current = yield* schedules.onCallAt(tenant.orgId, schedule.id, atMs).pipe(toError) const nextFrom = current.shift === null ? atMs : isoMs(current.shift.endsAt) - const upcoming = yield* schedules - .shifts(tenant.orgId, schedule.id, nextFrom, nextFrom + NEXT_SHIFT_HORIZON_MS) - .pipe( - Effect.catchTag("@maple/http/errors/AlertValidationError", () => Effect.succeed(null)), - toError, - ) + // A shift that ends at the lookaround edge was clamped there, not handed off: the + // "next" one would only be the same shift continuing. + const clamped = nextFrom >= atMs + ONCALL_LOOKAROUND_MS + const upcoming = clamped + ? null + : yield* schedules + .shifts(tenant.orgId, schedule.id, nextFrom, nextFrom + NEXT_SHIFT_HORIZON_MS) + .pipe( + Effect.catchTag("@maple/http/errors/AlertValidationError", () => + Effect.succeed(null), + ), + toError, + ) const next = upcoming === null ? Option.none() @@ -79,7 +92,9 @@ export function registerWhoIsOnCallTool(server: McpToolRegistrar) { [ "Next", output.next === null - ? "nobody within two weeks" + ? output.onCall === null + ? "nobody within two weeks" + : "no handoff within two weeks" : `${personLabel(output.next.user)} from ${output.next.startsAt} until ${output.next.endsAt}`, ], ]), diff --git a/packages/alerting-core/src/oncall.ts b/packages/alerting-core/src/oncall.ts index 22c03a89f7..b6fa7d5e0d 100644 --- a/packages/alerting-core/src/oncall.ts +++ b/packages/alerting-core/src/oncall.ts @@ -1,4 +1,5 @@ import { Array as Arr, DateTime, Option, Order, String as Str } from "effect" +import { ONCALL_LOOKAROUND_MS } from "@maple/domain/http" import type { OnCallLayer, OnCallOverride, @@ -18,7 +19,7 @@ export const MAX_ONCALL_RENDER_RANGE_MS = 90 * 86_400_000 /** Each layer yields at most this many rotation shifts per render; later ones are dropped. */ export const MAX_ONCALL_SHIFTS_PER_LAYER = 5_000 /** `onCallAt` resolves within this distance of the instant, so its bounds are clamped to it. */ -export const ONCALL_LOOKAROUND_MS = 15 * 86_400_000 +export { ONCALL_LOOKAROUND_MS } export type OnCallSource = | { readonly kind: "override"; readonly overrideId: OnCallOverrideId } diff --git a/packages/backend/src/services/alerts/OnCallSchedulesService.test.ts b/packages/backend/src/services/alerts/OnCallSchedulesService.test.ts index aa295bbfdc..ddc6a71eda 100644 --- a/packages/backend/src/services/alerts/OnCallSchedulesService.test.ts +++ b/packages/backend/src/services/alerts/OnCallSchedulesService.test.ts @@ -1,5 +1,6 @@ import { afterEach, assert, describe, it } from "@effect/vitest" import { + AlertMemberDirectoryNotConfiguredError, AlertValidationError, OnCallOverrideCreateRequest, OnCallScheduleCreateRequest, @@ -8,7 +9,7 @@ import { RoleName, UserId, } from "@maple/domain/http" -import { Effect, Exit, Layer, Schema, Array as Arr, String as Str } from "effect" +import { Effect, Exit, Layer, Option, Schema, Array as Arr, String as Str } from "effect" import { TestClock } from "effect/testing" import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@maple/backend/platform/test-pglite" import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" @@ -31,6 +32,25 @@ const at = (iso: string) => Date.parse(iso) const createdDbs: TestDb[] = [] afterEach(() => cleanupTestDbs(createdDbs)) +/** A deployment with no member directory, as self-hosted runs. */ +const noDirectoryLayer = (testDb: TestDb) => { + const notConfigured = new AlertMemberDirectoryNotConfiguredError({ + message: "The member directory is not configured", + }) + return Layer.effect(OnCallSchedulesService, OnCallSchedulesService.make).pipe( + Layer.provide( + Layer.merge( + testDb.layer, + Layer.succeed(OrgMembersService, { + ...stubOrgMembersService([]), + listMembers: () => Effect.fail(notConfigured), + resolveMembers: () => Effect.fail(notConfigured), + }), + ), + ), + ) +} + const makeLayer = (testDb: TestDb) => Layer.effect(OnCallSchedulesService, OnCallSchedulesService.make).pipe( Layer.provide( @@ -283,4 +303,55 @@ describe("OnCallSchedulesService", () => { assert.strictEqual(errorTag(gone), "@maple/http/errors/OnCallScheduleNotFoundError") }).pipe(Effect.provide(makeLayer(testDb))) }) + + it.effect("creates schedules and overrides on a deployment with no member directory", () => { + const testDb = createTestDb(createdDbs) + return Effect.gen(function* () { + yield* TestClock.setTime(at("2026-11-10T00:00:00Z")) + const schedules = yield* OnCallSchedulesService + const schedule = yield* schedules.create(ORG, ana, ADMIN, weekly) + const override = yield* schedules.createOverride(ORG, ana, ADMIN, schedule.id, benCoversAna) + assert.strictEqual(override.userId, ben) + assert.strictEqual((yield* schedules.memberNames(ORG)).size, 0) + }).pipe(Effect.provide(noDirectoryLayer(testDb))) + }) + + it.effect("lists who is on call now even past a thousand older overrides in the org", () => { + const testDb = createTestDb(createdDbs) + return Effect.gen(function* () { + yield* TestClock.setTime(at("2026-11-12T19:00:00Z")) + const schedules = yield* OnCallSchedulesService + const busy = yield* schedules.create( + ORG, + ana, + ADMIN, + new OnCallScheduleCreateRequest({ ...weekly, name: "Busy" }), + ) + const covered = yield* schedules.create(ORG, ana, ADMIN, weekly) + // Past overrides on another schedule, all starting before the active one. + yield* Effect.promise(() => + executeSql( + testDb, + `INSERT INTO oncall_overrides (id, org_id, schedule_id, user_id, starts_at, ends_at, created_by, created_at) + SELECT gen_random_uuid()::text, $1, $2, 'user_chen', + '2026-11-01T00:00:00Z'::timestamptz + n * interval '1 minute', + '2026-11-01T00:00:30Z'::timestamptz + n * interval '1 minute', 'seed', now() + FROM generate_series(1, 1200) AS n`, + [ORG, busy.id], + ), + ) + yield* schedules.createOverride(ORG, ana, ADMIN, covered.id, benCoversAna) + const listed = yield* schedules.list(ORG) + const row = Option.getOrUndefined(Arr.findFirst(listed.schedules, (s) => s.id === covered.id)) + assert.strictEqual(row?.onCallNow?.userId, ben) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("names members by their name only, never their email", () => { + const testDb = createTestDb(createdDbs) + return Effect.gen(function* () { + const names = yield* (yield* OnCallSchedulesService).memberNames(ORG) + assert.deepStrictEqual([names.get(ana), names.get(chen)], ["Ana Alves", undefined]) + }).pipe(Effect.provide(makeLayer(testDb))) + }) }) diff --git a/packages/backend/src/services/alerts/OnCallSchedulesService.ts b/packages/backend/src/services/alerts/OnCallSchedulesService.ts index 054b8dcea9..cb7b4c01fe 100644 --- a/packages/backend/src/services/alerts/OnCallSchedulesService.ts +++ b/packages/backend/src/services/alerts/OnCallSchedulesService.ts @@ -240,17 +240,19 @@ export class OnCallSchedulesService extends Context.Service< }), ) - /** Every id must be a current member of the org. */ + /** Every id must be a current member of the org; unchecked on a deployment with no directory. */ const requireMembers = (orgId: OrgId, userIds: ReadonlyArray) => orgMembers.resolveMembers(orgId, userIds).pipe( - Effect.catchTag("@maple/http/errors/AlertRecipientSelectionError", (error) => - Effect.fail( - validationError( - `Not members of this workspace: ${Arr.join(error.unknownUserIds, ", ")}`, - ), - ), - ), Effect.asVoid, + Effect.catchTags({ + "@maple/http/errors/AlertMemberDirectoryNotConfiguredError": () => Effect.void, + "@maple/http/errors/AlertRecipientSelectionError": (error) => + Effect.fail( + validationError( + `Not members of this workspace: ${Arr.join(error.unknownUserIds, ", ")}`, + ), + ), + }), ) const annotate = (scheduleId: OnCallScheduleId) => @@ -306,7 +308,7 @@ export class OnCallSchedulesService extends Context.Service< const list = Effect.fn("OnCallSchedulesService.list")(function* (orgId: OrgId) { const nowMs = yield* runtime.now const [from, to] = nowWindow(nowMs) - const [rows, overrides, chains] = yield* dbExecute((db) => + const [rows, nearby, active, chains] = yield* dbExecute((db) => Effect.all([ db.run( PG.from(OnCallSchedules) @@ -316,9 +318,17 @@ export class OnCallSchedulesService extends Context.Service< .limit(MAX_ONCALL_SCHEDULES_PER_ORG), ), overlappingOverrides(db, orgId, from, to), + // The org-wide window above is capped, so the overrides that decide who is on + // call now are read on their own: at most the upcoming cap per schedule. + db.run( + PG.from(OnCallOverrides) + .select() + .where(($) => [$.orgId.eq(orgId), $.startsAt.lte(nowMs), $.endsAt.gt(nowMs)]), + ), chainIdsBySchedule(db, orgId), ]), ) + const overrides = Arr.dedupeWith([...active, ...nearby], (a, b) => a.id === b.id) yield* Effect.annotateCurrentSpan("result.rowCount", rows.length) return new OnCallSchedulesListResponse({ schedules: Arr.map(rows, (row) => @@ -649,7 +659,10 @@ export class OnCallSchedulesService extends Context.Service< const memberNames = Effect.fn("OnCallSchedulesService.memberNames")(function* (orgId: OrgId) { const members = yield* orgMembers.listMembers(orgId).pipe(Effect.orElseSucceed(() => [])) - return new Map(Arr.map(members, (m) => [m.userId, m.name ?? m.email] as const)) + // Names only: an email is never a display name, and an unnamed member shows as their id. + return new Map( + Arr.flatMap(members, (m) => (m.name === null ? [] : [[m.userId, m.name] as const])), + ) }) return { diff --git a/packages/domain/src/http/oncall-schedules.ts b/packages/domain/src/http/oncall-schedules.ts index 78a4ed5fa5..c7f6385691 100644 --- a/packages/domain/src/http/oncall-schedules.ts +++ b/packages/domain/src/http/oncall-schedules.ts @@ -15,6 +15,8 @@ export const MAX_ONCALL_SCHEDULES_PER_ORG = 50 /** Overrides that have not ended yet, per schedule. */ export const MAX_ONCALL_FUTURE_OVERRIDES = 500 export const MAX_ONCALL_OVERRIDE_MS = 90 * 86_400_000 +/** Who is on call at an instant is resolved within this distance of it; shift bounds clamp to it. */ +export const ONCALL_LOOKAROUND_MS = 15 * 86_400_000 /** A local wall-clock time, "00:00" through "23:59". */ export const OnCallLocalTime = Schema.String.check( From 0a6a76b18940d0c270d9d686e995bbf1e357ea86 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:39:04 +0100 Subject: [PATCH 38/98] fix(alerts): an escalation step re-reads its incident before sending; chain detach guidance covers chain-only tiers --- apps/ai/src/mcp/lib/escalation-chains.ts | 2 +- .../src/services/alerts/AlertsService.ts | 23 +++++++++++++++---- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/ai/src/mcp/lib/escalation-chains.ts b/apps/ai/src/mcp/lib/escalation-chains.ts index 4bbf440b24..ccb1e99e7e 100644 --- a/apps/ai/src/mcp/lib/escalation-chains.ts +++ b/apps/ai/src/mcp/lib/escalation-chains.ts @@ -165,7 +165,7 @@ export const deleteChainErrors = { message: `Escalation chain is still used by ${Arr.join( Arr.map(error.ruleIds, (id, index) => `"${error.ruleNames[index] ?? id}" (${id})`), ", ", - )}. Detach it first with update_alert_rule escalation_chain_id="none".`, + )}. Detach it first with update_alert_rule escalation_chain_id="none"; a tier that pages only through the chain needs a destination first (destination_ids sets the first tier; a critical tier needs its own update).`, parameter: "chain_id", }), ), diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index bfa2fdc570..7c6906f3da 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -2459,16 +2459,29 @@ export class AlertsService extends Context.Service + db.run( + PG.from(AlertIncidents) + .select("status", "acknowledgedAt") + .where(($) => [$.orgId.eq(row.orgId), $.id.eq(stepIncidentId)]) + .limit(1), + ), + ))[0] ?? null) + : null + if ( + stepIncident !== null && + (stepIncident.status !== "open" || stepIncident.acknowledgedAt !== null) ) { yield* recordDeliveryFailure(row, claimTime, { message: - incidentRow.status !== "open" + stepIncident.status !== "open" ? "Not sent: the incident resolved before this escalation step went out" : "Not sent: the incident was acknowledged before this escalation step went out", kind: "escalation", From 2893824ba132b9b74258be373be76ebd13638c31 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:42:02 +0100 Subject: [PATCH 39/98] refactor(alerts): pages read in their own service, so acknowledging needs no member directory AlertIncidentPagesService owns the escalation pages read and its member names; AlertIncidentAckService is back to ack activity only, so the chat bot can build it without Env. Also drops a vendor name from a ChatAlertPoster comment. --- apps/ai/src/mcp/dispatcher.ts | 2 + .../mcp/tools/__tests__/alert-tools.test.ts | 4 +- .../ai/src/mcp/tools/get-incident-timeline.ts | 6 +- apps/ai/src/mcp/tools/runtime-requirements.ts | 2 + .../test/mcp/unimplemented-tool-services.ts | 2 + .../api/src/routes/v2/alert-incidents.http.ts | 9 +- apps/api/src/routes/v2/v2-test-support.ts | 10 +- apps/api/src/runtime/service-graph.ts | 2 + .../alerts/AlertIncidentAck.chat.test.ts | 7 +- .../alerts/AlertIncidentAckService.test.ts | 7 +- .../alerts/AlertIncidentAckService.ts | 86 +----------- .../alerts/AlertIncidentPagesService.test.ts | 58 +++++++++ .../alerts/AlertIncidentPagesService.ts | 122 ++++++++++++++++++ .../AlertsService.oncall-paging.test.ts | 19 +-- .../src/services/alerts/ChatAlertPoster.ts | 2 +- 15 files changed, 220 insertions(+), 118 deletions(-) create mode 100644 packages/backend/src/services/alerts/AlertIncidentPagesService.test.ts create mode 100644 packages/backend/src/services/alerts/AlertIncidentPagesService.ts diff --git a/apps/ai/src/mcp/dispatcher.ts b/apps/ai/src/mcp/dispatcher.ts index c62b7ed876..a2100975e7 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 { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" +import { AlertIncidentPagesService } from "@maple/backend/services/alerts/AlertIncidentPagesService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" @@ -274,6 +275,7 @@ const McpRuntimeServicesLive = Layer.mergeAll( AgentFeedbackService.layer, AlertReadModelsService.layer, AlertIncidentAckService.layer, + AlertIncidentPagesService.layer, AlertRulesService.layer, EscalationChainsService.layer, OnCallSchedulesService.layer, 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 e9f7cdab6c..f7f66f4933 100644 --- a/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts @@ -34,7 +34,7 @@ import { import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" import { AlertsService } from "@maple/backend/services/alerts/AlertsService" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" -import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" +import { AlertIncidentPagesService } from "@maple/backend/services/alerts/AlertIncidentPagesService" import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" import { CurrentMcpTenant } from "../../lib/query-warehouse" import { executeRegisteredMcpToolUnscoped } from "../registry" @@ -289,7 +289,7 @@ const layer = (seen: Seen) => }), ), } as never), - Layer.mock(AlertIncidentAckService)({ + Layer.mock(AlertIncidentPagesService)({ listPages: (_org, incidentIds) => Effect.succeed( Arr.flatMap(incidentIds, (incidentId) => diff --git a/apps/ai/src/mcp/tools/get-incident-timeline.ts b/apps/ai/src/mcp/tools/get-incident-timeline.ts index f938bed341..274341b97d 100644 --- a/apps/ai/src/mcp/tools/get-incident-timeline.ts +++ b/apps/ai/src/mcp/tools/get-incident-timeline.ts @@ -3,7 +3,7 @@ import { DateTime, Effect, 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 { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" +import { AlertIncidentPagesService } from "@maple/backend/services/alerts/AlertIncidentPagesService" import { ackLabel, ALERT_INCIDENT_STATUSES, @@ -102,8 +102,8 @@ export function registerGetIncidentTimelineTool(server: McpToolRegistrar) { ) const incidents = matching.slice(0, params.limit) // One read for every listed incident's pages. - const acks = yield* AlertIncidentAckService - const pages = yield* acks + const pagesService = yield* AlertIncidentPagesService + const pages = yield* pagesService .listPages( tenant.orgId, Arr.map(incidents, (i) => i.id), diff --git a/apps/ai/src/mcp/tools/runtime-requirements.ts b/apps/ai/src/mcp/tools/runtime-requirements.ts index 761fbbc8e4..a03046013b 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 { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" +import type { AlertIncidentPagesService } from "@maple/backend/services/alerts/AlertIncidentPagesService" import type { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" import type { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" import type { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" @@ -50,6 +51,7 @@ export type McpToolRuntimeRequirements = | DependencyScanReader | AlertReadModelsService | AlertIncidentAckService + | AlertIncidentPagesService | AlertRulesService | EscalationChainsService | OnCallSchedulesService diff --git a/apps/ai/test/mcp/unimplemented-tool-services.ts b/apps/ai/test/mcp/unimplemented-tool-services.ts index ade3e8d50a..9b0e0b499c 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 { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" +import { AlertIncidentPagesService } from "@maple/backend/services/alerts/AlertIncidentPagesService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" @@ -46,6 +47,7 @@ export const unimplementedMcpToolServices = Layer.mergeAll( Layer.mock(AlertsService)({}), Layer.mock(AlertReadModelsService)({}), Layer.mock(AlertIncidentAckService)({}), + Layer.mock(AlertIncidentPagesService)({}), Layer.mock(AlertRulesService)({}), Layer.mock(EscalationChainsService)({}), Layer.mock(OnCallSchedulesService)({}), diff --git a/apps/api/src/routes/v2/alert-incidents.http.ts b/apps/api/src/routes/v2/alert-incidents.http.ts index fbe487b54d..97fcdcedb6 100644 --- a/apps/api/src/routes/v2/alert-incidents.http.ts +++ b/apps/api/src/routes/v2/alert-incidents.http.ts @@ -9,6 +9,10 @@ import { AlertIncidentAckService, type AlertIncidentActor, } from "@maple/backend/services/alerts/AlertIncidentAckService" +import { + AlertIncidentPagesService, + mergePagesIntoEvents, +} from "@maple/backend/services/alerts/AlertIncidentPagesService" import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" import { CurrentAuditActor } from "@maple/backend/services/auth/audit-actor" @@ -76,6 +80,7 @@ export const HttpV2AlertIncidentsLive = HttpApiBuilder.group(MapleApiV2, "alertI Effect.gen(function* () { const readModels = yield* AlertReadModelsService const acks = yield* AlertIncidentAckService + const pagesService = yield* AlertIncidentPagesService const members = yield* OrgMembersService // Who is acknowledging, with a name recorded alongside so every surface can show it without a @@ -148,7 +153,9 @@ export const HttpV2AlertIncidentsLive = HttpApiBuilder.group(MapleApiV2, "alertI .handle("listEvents", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const events = yield* acks.listEvents(tenant.orgId, params.id) + const ackEvents = yield* acks.listEvents(tenant.orgId, params.id) + const pages = yield* pagesService.listPages(tenant.orgId, [params.id]) + const events = mergePagesIntoEvents(ackEvents, pages) return { object: "list" as const, data: Arr.map(events, toV2Event) } }), ) diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index b87e16d04d..ea4b96ba54 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 { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" +import { AlertIncidentPagesService } from "@maple/backend/services/alerts/AlertIncidentPagesService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" @@ -188,12 +189,13 @@ const v2GroupLayersExceptOnboardingChecklist = ( HttpV2AlertDeliveriesLive, HttpV2AlertRulesLive, HttpV2AlertDestinationsLive, - // Real ack service over the Database. API keys are named by their label, so the - // empty member directory is never asked for one; pages it names go unnamed. + // Real ack and pages services over the Database. API keys are named by their label, so + // the empty member directory is never asked for one; pages it names go unnamed. HttpV2AlertIncidentsLive.pipe( Layer.provide( - Layer.merge( - Layer.effect(AlertIncidentAckService, AlertIncidentAckService.make).pipe( + Layer.mergeAll( + Layer.effect(AlertIncidentAckService, AlertIncidentAckService.make), + Layer.effect(AlertIncidentPagesService, AlertIncidentPagesService.make).pipe( Layer.provide(OrgMembersServiceStubLayer), ), OrgMembersServiceStubLayer, diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index 9ce5186d01..1d4d0f1d9e 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 { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" +import { AlertIncidentPagesService } from "@maple/backend/services/alerts/AlertIncidentPagesService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" @@ -181,6 +182,7 @@ export const HttpServicesLive = Layer.mergeAll( AlertDestinationsService.layer, AlertReadModelsService.layer, AlertIncidentAckService.layer, + AlertIncidentPagesService.layer, AlertRulesService.layer, AlertsService.layer, EscalationChainsService.layer, diff --git a/packages/backend/src/services/alerts/AlertIncidentAck.chat.test.ts b/packages/backend/src/services/alerts/AlertIncidentAck.chat.test.ts index e80c9d508a..4a2e96807b 100644 --- a/packages/backend/src/services/alerts/AlertIncidentAck.chat.test.ts +++ b/packages/backend/src/services/alerts/AlertIncidentAck.chat.test.ts @@ -8,8 +8,6 @@ import { Effect, Layer, Option, Schema, Array as Arr } from "effect" import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@maple/backend/platform/test-pglite" import { AlertChatCardService } from "./AlertChatCardService" import { AlertIncidentAckService, type AlertIncidentActor } from "./AlertIncidentAckService" -import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" -import { stubOrgMembersService } from "./alerts-service-test-support" const ORG = Schema.decodeSync(OrgId)("org_ack_chat") const RULE = Schema.decodeSync(AlertRuleId)("00000000-0000-4000-8000-000000000030") @@ -29,12 +27,9 @@ const sam: AlertIncidentActor = { userId: null, name: "sam", source: "slack" } const createdDbs: TestDb[] = [] afterEach(() => cleanupTestDbs(createdDbs)) -// The member directory is stubbed: it names the people the ack timeline shows, not the cards. const makeLayer = (testDb: TestDb) => Layer.mergeAll( - Layer.effect(AlertIncidentAckService, AlertIncidentAckService.make).pipe( - Layer.provide(Layer.succeed(OrgMembersService, stubOrgMembersService())), - ), + Layer.effect(AlertIncidentAckService, AlertIncidentAckService.make), AlertChatCardService.layer, ).pipe(Layer.provide(testDb.layer)) diff --git a/packages/backend/src/services/alerts/AlertIncidentAckService.test.ts b/packages/backend/src/services/alerts/AlertIncidentAckService.test.ts index 67fbf5ea35..ea299e2368 100644 --- a/packages/backend/src/services/alerts/AlertIncidentAckService.test.ts +++ b/packages/backend/src/services/alerts/AlertIncidentAckService.test.ts @@ -3,9 +3,7 @@ import { AlertIncidentId, AlertRuleId, OrgId, UserId } from "@maple/domain/http" import { Duration, Effect, Layer, Schema, Array as Arr } from "effect" import { TestClock } from "effect/testing" import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@maple/backend/platform/test-pglite" -import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" import { AlertIncidentAckService, type AlertIncidentActor } from "./AlertIncidentAckService" -import { stubOrgMembersService } from "./alerts-service-test-support" const asOrgId = Schema.decodeUnknownSync(OrgId) const asRuleId = Schema.decodeUnknownSync(AlertRuleId) @@ -25,10 +23,7 @@ const createdDbs: TestDb[] = [] afterEach(() => cleanupTestDbs(createdDbs)) const makeLayer = (testDb: TestDb) => - Layer.effect(AlertIncidentAckService, AlertIncidentAckService.make).pipe( - Layer.provide(testDb.layer), - Layer.provide(Layer.succeed(OrgMembersService, stubOrgMembersService())), - ) + Layer.effect(AlertIncidentAckService, AlertIncidentAckService.make).pipe(Layer.provide(testDb.layer)) const seed = (testDb: TestDb) => Effect.promise(() => diff --git a/packages/backend/src/services/alerts/AlertIncidentAckService.ts b/packages/backend/src/services/alerts/AlertIncidentAckService.ts index 9cd1de94ed..bb82fec81e 100644 --- a/packages/backend/src/services/alerts/AlertIncidentAckService.ts +++ b/packages/backend/src/services/alerts/AlertIncidentAckService.ts @@ -12,7 +12,6 @@ import { AlertIncidentNotFoundError, AlertIncidentResolvedError, type AlertIncidentDocument, - type AlertIncidentPage, type AlertIncidentId, type AlertPersistenceError, type OrgId, @@ -24,15 +23,13 @@ import { AlertDestinations, AlertIncidentEvents, AlertIncidents, - AlertMemberNotifications, type AlertIncidentRow, } from "@maple/db/tables" import { makeAlertDeliveryKey } from "@maple/alerting-core" import type { MapleDb } from "@maple/db/client" -import { Context, Effect, Layer, Option, Order, Schema, Array as Arr } from "effect" +import { Context, Effect, Layer, Option, Schema, Array as Arr } from "effect" import { Database } from "@maple/backend/platform/DatabaseLive" import { makeDbExecute } from "@maple/backend/platform/db-execute" -import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" import { makePersistenceError } from "./alert-persistence" import { AlertRuntime } from "./AlertRuntime" import { rowToIncidentDocument } from "./AlertReadModelsService" @@ -50,16 +47,6 @@ export interface AlertIncidentAckResult { readonly changed: boolean } -export interface AlertIncidentPageEntry { - readonly id: string - readonly incidentId: AlertIncidentId - readonly page: AlertIncidentPage - readonly createdAt: number -} - -/** Pages per incident the timeline shows; a chain pages at most a few people a step. */ -const MAX_PAGES_PER_READ = 500 - type AckError = AlertIncidentNotFoundError | AlertIncidentResolvedError | AlertPersistenceError export interface AlertIncidentAckServiceApi { @@ -73,11 +60,6 @@ export interface AlertIncidentAckServiceApi { incidentId: AlertIncidentId, actor: AlertIncidentActor, ) => Effect.Effect - /** People escalation steps paged for these incidents, oldest first; one read for all of them. */ - readonly listPages: ( - orgId: OrgId, - incidentIds: ReadonlyArray, - ) => Effect.Effect, AlertPersistenceError> /** The incident's activity, oldest first. */ readonly listEvents: ( orgId: OrgId, @@ -100,7 +82,6 @@ export class AlertIncidentAckService extends Context.Service< >()("@maple/backend/services/alerts/AlertIncidentAckService", { make: Effect.gen(function* () { const database = yield* Database - const orgMembers = yield* OrgMembersService const dbExecute = makeDbExecute(database, "AlertIncidentAckService", makePersistenceError) const findIncident = (orgId: OrgId, incidentId: AlertIncidentId) => @@ -306,46 +287,6 @@ export class AlertIncidentAckService extends Context.Service< return { incident: rowToIncidentDocument(current), changed: false } }) - const listPages = Effect.fn("AlertIncidentAckService.listPages")(function* ( - orgId: OrgId, - incidentIds: ReadonlyArray, - ) { - if (incidentIds.length === 0) return [] - const rows = yield* dbExecute((db) => - db.run( - PG.from(AlertMemberNotifications) - .select( - "id", - "incidentId", - "userId", - "channel", - "status", - "errorMessage", - "createdAt", - ) - .where(($) => [$.orgId.eq(orgId), $.incidentId.in_(...incidentIds)]) - .orderBy(["createdAt", "asc"], ["id", "asc"]) - .limit(MAX_PAGES_PER_READ), - ), - ) - if (rows.length === 0) return [] - // Display names only, never the address; a directory that cannot be read leaves them null. - const members = yield* orgMembers.listMembers(orgId).pipe(Effect.orElseSucceed(() => [])) - const names = new Map(Arr.map(members, (member) => [member.userId, member.name] as const)) - return Arr.map(rows, (row): AlertIncidentPageEntry => ({ - id: row.id, - incidentId: row.incidentId, - createdAt: row.createdAt, - page: { - userId: row.userId, - name: names.get(row.userId) ?? null, - channel: row.channel, - status: row.status, - errorMessage: row.errorMessage, - }, - })) - }) - const listEvents = Effect.fn("AlertIncidentAckService.listEvents")(function* ( orgId: OrgId, incidentId: AlertIncidentId, @@ -362,9 +303,8 @@ export class AlertIncidentAckService extends Context.Service< .limit(500), ), ) - const pages = yield* listPages(orgId, [incidentId]) // A type this build does not know (a newer writer) is skipped rather than failing the page. - const acks = Arr.getSomes( + return Arr.getSomes( Arr.map(Arr.reverse(rows), (row) => Option.map( decodeEventType(row.type), @@ -382,34 +322,14 @@ export class AlertIncidentAckService extends Context.Service< ), ), ) - const paged = Arr.map( - pages, - (entry) => - new AlertIncidentEventDocument({ - id: decodeEventId(entry.id), - incidentId: entry.incidentId, - type: "paged", - actorUserId: null, - actorName: null, - source: null, - page: entry.page, - createdAt: decodeIso(new Date(entry.createdAt).toISOString()), - }), - ) - // Stable: an ack and a page in the same millisecond keep the ack first. - return Arr.sort( - [...acks, ...paged], - Order.mapInput(Order.String, (event: AlertIncidentEventDocument) => event.createdAt), - ) }) return { acknowledge: setAcknowledged(true), unacknowledge: setAcknowledged(false), - listPages, listEvents, } satisfies AlertIncidentAckServiceApi }), }) { - static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(OrgMembersService.layer)) + static readonly layer = Layer.effect(this, this.make) } diff --git a/packages/backend/src/services/alerts/AlertIncidentPagesService.test.ts b/packages/backend/src/services/alerts/AlertIncidentPagesService.test.ts new file mode 100644 index 0000000000..4304545714 --- /dev/null +++ b/packages/backend/src/services/alerts/AlertIncidentPagesService.test.ts @@ -0,0 +1,58 @@ +import { assert, describe, it } from "@effect/vitest" +import { AlertIncidentEventDocument, AlertIncidentEventId, AlertIncidentId, UserId } from "@maple/domain/http" +import { Schema, Array as Arr } from "effect" +import { mergePagesIntoEvents, type AlertIncidentPageEntry } from "./AlertIncidentPagesService" + +const INCIDENT = Schema.decodeSync(AlertIncidentId)("00000000-0000-4000-8000-000000000031") +const asEventId = Schema.decodeSync(AlertIncidentEventId) +const asIso = Schema.decodeSync(AlertIncidentEventDocument.fields.createdAt) +const T0 = Date.parse("2026-11-06T10:00:00.000Z") + +const ack = (id: string, at: number) => + new AlertIncidentEventDocument({ + id: asEventId(id), + incidentId: INCIDENT, + type: "acknowledged", + actorUserId: null, + actorName: "Dana Kim", + source: "web", + createdAt: asIso(new Date(at).toISOString()), + }) + +const page = (id: string, at: number): AlertIncidentPageEntry => ({ + id, + incidentId: INCIDENT, + createdAt: at, + page: { + userId: Schema.decodeSync(UserId)("user_ben"), + name: "Ben Brook", + channel: "email", + status: "sent", + errorMessage: null, + }, +}) + +describe("mergePagesIntoEvents", () => { + it("orders acks and pages by time, an ack first when both share a millisecond", () => { + const events = mergePagesIntoEvents( + [ + ack("00000000-0000-4000-8000-0000000000a1", T0 + 1000), + ack("00000000-0000-4000-8000-0000000000a2", T0 + 3000), + ], + [ + page("00000000-0000-4000-8000-0000000000b1", T0), + page("00000000-0000-4000-8000-0000000000b2", T0 + 1000), + ], + ) + assert.deepStrictEqual( + Arr.map(events, (event) => [event.type, event.createdAt]), + [ + ["paged", new Date(T0).toISOString()], + ["acknowledged", new Date(T0 + 1000).toISOString()], + ["paged", new Date(T0 + 1000).toISOString()], + ["acknowledged", new Date(T0 + 3000).toISOString()], + ], + ) + assert.strictEqual(events[0]?.page?.name, "Ben Brook") + }) +}) diff --git a/packages/backend/src/services/alerts/AlertIncidentPagesService.ts b/packages/backend/src/services/alerts/AlertIncidentPagesService.ts new file mode 100644 index 0000000000..68b59895cf --- /dev/null +++ b/packages/backend/src/services/alerts/AlertIncidentPagesService.ts @@ -0,0 +1,122 @@ +/** + * Who escalation paged for an alert incident. Kept apart from acknowledging so that writing an + * ack never needs the member directory, which is what puts display names on these entries. + */ +import { + AlertIncidentEventDocument, + AlertIncidentEventId, + type AlertIncidentId, + type AlertIncidentPage, + type AlertPersistenceError, + type OrgId, +} from "@maple/domain/http" +import * as PG from "@maple-dev/effect-orm/postgres" +import { AlertMemberNotifications } from "@maple/db/tables" +import { Context, Effect, Layer, Order, Schema, Array as Arr } from "effect" +import { Database } from "@maple/backend/platform/DatabaseLive" +import { makeDbExecute } from "@maple/backend/platform/db-execute" +import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" +import { makePersistenceError } from "./alert-persistence" + +export interface AlertIncidentPageEntry { + readonly id: string + readonly incidentId: AlertIncidentId + readonly page: AlertIncidentPage + readonly createdAt: number +} + +/** Pages per incident the timeline shows; a chain pages at most a few people a step. */ +const MAX_PAGES_PER_READ = 500 + +export interface AlertIncidentPagesServiceApi { + /** People escalation steps paged for these incidents, oldest first; one read for all of them. */ + readonly listPages: ( + orgId: OrgId, + incidentIds: ReadonlyArray, + ) => Effect.Effect, AlertPersistenceError> +} + +const decodeEventId = Schema.decodeUnknownSync(AlertIncidentEventId) +const decodeIso = Schema.decodeUnknownSync(AlertIncidentEventDocument.fields.createdAt) + +/** Page entries as `paged` timeline events, merged with ack activity in time order. */ +export const mergePagesIntoEvents = ( + acks: ReadonlyArray, + pages: ReadonlyArray, +): ReadonlyArray => { + const paged = Arr.map( + pages, + (entry) => + new AlertIncidentEventDocument({ + id: decodeEventId(entry.id), + incidentId: entry.incidentId, + type: "paged", + actorUserId: null, + actorName: null, + source: null, + page: entry.page, + createdAt: decodeIso(new Date(entry.createdAt).toISOString()), + }), + ) + // Stable: an ack and a page in the same millisecond keep the ack first. + return Arr.sort( + [...acks, ...paged], + Order.mapInput(Order.String, (event: AlertIncidentEventDocument) => event.createdAt), + ) +} + +export class AlertIncidentPagesService extends Context.Service< + AlertIncidentPagesService, + AlertIncidentPagesServiceApi +>()("@maple/backend/services/alerts/AlertIncidentPagesService", { + make: Effect.gen(function* () { + const database = yield* Database + const orgMembers = yield* OrgMembersService + const dbExecute = makeDbExecute(database, "AlertIncidentPagesService", makePersistenceError) + + const listPages = Effect.fn("AlertIncidentPagesService.listPages")(function* ( + orgId: OrgId, + incidentIds: ReadonlyArray, + ) { + yield* Effect.annotateCurrentSpan({ orgId, "maple.alert.incident_count": incidentIds.length }) + if (incidentIds.length === 0) return [] + const rows = yield* dbExecute((db) => + db.run( + PG.from(AlertMemberNotifications) + .select( + "id", + "incidentId", + "userId", + "channel", + "status", + "errorMessage", + "createdAt", + ) + .where(($) => [$.orgId.eq(orgId), $.incidentId.in_(...incidentIds)]) + .orderBy(["createdAt", "asc"], ["id", "asc"]) + .limit(MAX_PAGES_PER_READ), + ), + ) + if (rows.length === 0) return [] + // Display names only, never the address; a directory that cannot be read leaves them null. + const members = yield* orgMembers.listMembers(orgId).pipe(Effect.orElseSucceed(() => [])) + const names = new Map(Arr.map(members, (member) => [member.userId, member.name] as const)) + return Arr.map(rows, (row): AlertIncidentPageEntry => ({ + id: row.id, + incidentId: row.incidentId, + createdAt: row.createdAt, + page: { + userId: row.userId, + name: names.get(row.userId) ?? null, + channel: row.channel, + status: row.status, + errorMessage: row.errorMessage, + }, + })) + }) + + return { listPages } satisfies AlertIncidentPagesServiceApi + }), +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(OrgMembersService.layer)) +} diff --git a/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts b/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts index 00245c7099..a5774addbb 100644 --- a/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts +++ b/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts @@ -31,7 +31,7 @@ import { import { EmailService } from "@maple/backend/platform/EmailService" import { OrgMembersService, type OrgMember } from "@maple/backend/services/org/OrgMembersService" import { AlertsService, type AlertsServiceApi } from "./AlertsService" -import { AlertIncidentAckService } from "./AlertIncidentAckService" +import { AlertIncidentPagesService } from "./AlertIncidentPagesService" import { ChatAlertPoster } from "./ChatAlertPoster" import { EscalationChainsService } from "./EscalationChainsService" import { OnCallSchedulesService } from "./OnCallSchedulesService" @@ -231,7 +231,7 @@ const layerFor = ( Layer.provide(testDb.layer), Layer.provide(members), ), - Layer.effect(AlertIncidentAckService, AlertIncidentAckService.make).pipe( + Layer.effect(AlertIncidentPagesService, AlertIncidentPagesService.make).pipe( Layer.provide(testDb.layer), Layer.provide(members), ), @@ -480,21 +480,16 @@ describe("escalation pages the on-call", () => { // The timeline names him and the channel, never the address. const [incident] = (yield* alerts.listIncidents(orgId, {})).incidents if (incident === undefined) return assert.fail("expected an incident") - const events = yield* (yield* AlertIncidentAckService).listEvents(orgId, incident.id) + const pages = yield* (yield* AlertIncidentPagesService).listPages(orgId, [incident.id]) // Both pages went out in the same step, so their order is not fixed. assert.sameDeepMembers( - Arr.map(events, (event) => [ - event.type, - event.page?.name, - event.page?.channel, - event.page?.status, - ]), + Arr.map(pages, (entry) => [entry.page.name, entry.page.channel, entry.page.status]), [ - ["paged", "Ben Brook", "email", "sent"], - ["paged", "Ben Brook", "slack", "sent"], + ["Ben Brook", "email", "sent"], + ["Ben Brook", "slack", "sent"], ], ) - assert.notInclude(JSON.stringify(events), "ben@acme.test") + assert.notInclude(JSON.stringify(pages), "ben@acme.test") }).pipe(Effect.provide(layerFor(testDb, state, emails, chat))) }) }) diff --git a/packages/backend/src/services/alerts/ChatAlertPoster.ts b/packages/backend/src/services/alerts/ChatAlertPoster.ts index 24992f6436..575c07710a 100644 --- a/packages/backend/src/services/alerts/ChatAlertPoster.ts +++ b/packages/backend/src/services/alerts/ChatAlertPoster.ts @@ -53,7 +53,7 @@ export const chatDeliveryFailure = (error: ChatOutboundError): AlertDeliveryFail return new AlertDeliveryTargetMissingError(fields) case "rejected": return new AlertDeliveryRejectedError(fields) - // Asked to slow down: retried even when the status reads like a refusal (Discord's 400). + // Asked to slow down: retried even when the status reads like a refusal (a connector that reports a rate limit with a 400). case "rate_limited": return new AlertDeliveryError(fields) case undefined: From ab96cb071e54cc5d28c3d7ff7faf566f79ca0779 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:44:01 +0100 Subject: [PATCH 40/98] fix(mcp): delete_escalation_chain's description says a chain-only tier needs a destination before detaching --- apps/ai/src/mcp/tool-descriptors.generated.ts | 2 +- apps/ai/src/mcp/tools/delete-escalation-chain.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ai/src/mcp/tool-descriptors.generated.ts b/apps/ai/src/mcp/tool-descriptors.generated.ts index 6004aa6db4..5db102700d 100644 --- a/apps/ai/src/mcp/tool-descriptors.generated.ts +++ b/apps/ai/src/mcp/tool-descriptors.generated.ts @@ -41,7 +41,7 @@ export const MCP_TOOL_DESCRIPTORS: ReadonlyArray = [ {"name":"list_escalation_chains","title":"List Escalation Chains","description":"List escalation chains: ordered paging steps that notify destinations one step at a time until an incident is acknowledged or resolved. Shows each chain's steps, repeats and the rules using it. Attach one to a rule with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chains":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["chains","total"],"additionalProperties":true},"annotations":{"title":"List Escalation Chains","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"create_escalation_chain","title":"Create Escalation Chain","description":"Create an escalation chain: steps that each notify their destinations, then wait for an acknowledgement before the next step. A rule tier that names the chain still notifies its own destinations at once; the chain pages on top until the incident is acknowledged or resolved. Attach it with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"Chain name, e.g. 'Checkout on-call'"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["destination_ids","wait_minutes"],"additionalProperties":false}},"description":"Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":10},{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. At most 10 steps of 10 destinations"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5 (default 0)"},"description":{"type":"string","description":"Free-form notes"}},"required":["name","steps"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Create Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, {"name":"update_escalation_chain","title":"Update Escalation Chain","description":"Change an escalation chain's name, steps, repeats or description. Pass only what changes; steps replaces every step. Escalations already running finish on the steps they started with. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to change: its UUID or its REST id (esc_\u2026)"},"name":{"type":"string","description":"New name"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["destination_ids","wait_minutes"],"additionalProperties":false}},"description":"Replaces every step. Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":10},{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. At most 10 steps of 10 destinations"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5"},"description":{"type":"string","description":"New notes"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Update Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, - {"name":"delete_escalation_chain","title":"Delete Escalation Chain","description":"Delete an escalation chain. Refused while a rule tier still names it: detach it with update_alert_rule escalation_chain_id=\"none\" first. Escalations already running finish. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to delete: its UUID or its REST id (esc_\u2026)"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"additionalProperties":true},"annotations":{"title":"Delete Escalation Chain","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}}, + {"name":"delete_escalation_chain","title":"Delete Escalation Chain","description":"Delete an escalation chain. Refused while a rule tier still names it: detach it with update_alert_rule escalation_chain_id=\"none\" first; a tier that pages only through the chain needs a destination before that. Escalations already running finish. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to delete: its UUID or its REST id (esc_\u2026)"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"additionalProperties":true},"annotations":{"title":"Delete Escalation Chain","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"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"}]},"spaceAggregation":{"anyOf":[{"type":"string","enum":["sum","avg","min","max","count"]},{"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/delete-escalation-chain.ts b/apps/ai/src/mcp/tools/delete-escalation-chain.ts index 73dab98df8..518ce73bab 100644 --- a/apps/ai/src/mcp/tools/delete-escalation-chain.ts +++ b/apps/ai/src/mcp/tools/delete-escalation-chain.ts @@ -12,7 +12,7 @@ export function registerDeleteEscalationChainTool(server: McpToolRegistrar) { name: "delete_escalation_chain", title: "Delete Escalation Chain", description: - 'Delete an escalation chain. Refused while a rule tier still names it: detach it with update_alert_rule escalation_chain_id="none" first. ' + + 'Delete an escalation chain. Refused while a rule tier still names it: detach it with update_alert_rule escalation_chain_id="none" first; a tier that pages only through the chain needs a destination before that. ' + "Escalations already running finish. Ids from list_escalation_chains.", parameters: Schema.Struct({ chain_id: P.resourceId("escalationChain", "Escalation chain to delete"), From 81bd621ecc2d8ea654403cd98547c449323dd220 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:44:42 +0100 Subject: [PATCH 41/98] fix(alerts): escalation spans record the org as orgId; the detach hint names how a critical tier gains a destination --- apps/ai/src/mcp/lib/escalation-chains.ts | 2 +- apps/ai/src/mcp/tool-descriptors.generated.ts | 2 +- apps/ai/src/mcp/tools/delete-escalation-chain.ts | 2 +- packages/backend/src/services/alerts/AlertsService.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ai/src/mcp/lib/escalation-chains.ts b/apps/ai/src/mcp/lib/escalation-chains.ts index ccb1e99e7e..6e5ab5157a 100644 --- a/apps/ai/src/mcp/lib/escalation-chains.ts +++ b/apps/ai/src/mcp/lib/escalation-chains.ts @@ -165,7 +165,7 @@ export const deleteChainErrors = { message: `Escalation chain is still used by ${Arr.join( Arr.map(error.ruleIds, (id, index) => `"${error.ruleNames[index] ?? id}" (${id})`), ", ", - )}. Detach it first with update_alert_rule escalation_chain_id="none"; a tier that pages only through the chain needs a destination first (destination_ids sets the first tier; a critical tier needs its own update).`, + )}. Detach it first with update_alert_rule escalation_chain_id="none"; a tier that pages only through the chain needs a destination first (destination_ids does that on a one-tier rule; a two-tier rule's critical tier gains one by replacing the rule's thresholds in the v2 API or the dashboard).`, parameter: "chain_id", }), ), diff --git a/apps/ai/src/mcp/tool-descriptors.generated.ts b/apps/ai/src/mcp/tool-descriptors.generated.ts index 5db102700d..2b66db4741 100644 --- a/apps/ai/src/mcp/tool-descriptors.generated.ts +++ b/apps/ai/src/mcp/tool-descriptors.generated.ts @@ -41,7 +41,7 @@ export const MCP_TOOL_DESCRIPTORS: ReadonlyArray = [ {"name":"list_escalation_chains","title":"List Escalation Chains","description":"List escalation chains: ordered paging steps that notify destinations one step at a time until an incident is acknowledged or resolved. Shows each chain's steps, repeats and the rules using it. Attach one to a rule with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chains":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["chains","total"],"additionalProperties":true},"annotations":{"title":"List Escalation Chains","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"create_escalation_chain","title":"Create Escalation Chain","description":"Create an escalation chain: steps that each notify their destinations, then wait for an acknowledgement before the next step. A rule tier that names the chain still notifies its own destinations at once; the chain pages on top until the incident is acknowledged or resolved. Attach it with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"Chain name, e.g. 'Checkout on-call'"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["destination_ids","wait_minutes"],"additionalProperties":false}},"description":"Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":10},{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. At most 10 steps of 10 destinations"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5 (default 0)"},"description":{"type":"string","description":"Free-form notes"}},"required":["name","steps"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Create Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, {"name":"update_escalation_chain","title":"Update Escalation Chain","description":"Change an escalation chain's name, steps, repeats or description. Pass only what changes; steps replaces every step. Escalations already running finish on the steps they started with. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to change: its UUID or its REST id (esc_\u2026)"},"name":{"type":"string","description":"New name"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["destination_ids","wait_minutes"],"additionalProperties":false}},"description":"Replaces every step. Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":10},{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. At most 10 steps of 10 destinations"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5"},"description":{"type":"string","description":"New notes"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Update Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, - {"name":"delete_escalation_chain","title":"Delete Escalation Chain","description":"Delete an escalation chain. Refused while a rule tier still names it: detach it with update_alert_rule escalation_chain_id=\"none\" first; a tier that pages only through the chain needs a destination before that. Escalations already running finish. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to delete: its UUID or its REST id (esc_\u2026)"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"additionalProperties":true},"annotations":{"title":"Delete Escalation Chain","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}}, + {"name":"delete_escalation_chain","title":"Delete Escalation Chain","description":"Delete an escalation chain. Refused while a rule tier still names it: detach it with update_alert_rule escalation_chain_id=\"none\" first; a tier that pages only through the chain needs a destination before that (destination_ids on a one-tier rule; the rule's thresholds via the v2 API or dashboard on a two-tier rule). Escalations already running finish. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to delete: its UUID or its REST id (esc_\u2026)"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"additionalProperties":true},"annotations":{"title":"Delete Escalation Chain","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"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"}]},"spaceAggregation":{"anyOf":[{"type":"string","enum":["sum","avg","min","max","count"]},{"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/delete-escalation-chain.ts b/apps/ai/src/mcp/tools/delete-escalation-chain.ts index 518ce73bab..4fa933cd06 100644 --- a/apps/ai/src/mcp/tools/delete-escalation-chain.ts +++ b/apps/ai/src/mcp/tools/delete-escalation-chain.ts @@ -12,7 +12,7 @@ export function registerDeleteEscalationChainTool(server: McpToolRegistrar) { name: "delete_escalation_chain", title: "Delete Escalation Chain", description: - 'Delete an escalation chain. Refused while a rule tier still names it: detach it with update_alert_rule escalation_chain_id="none" first; a tier that pages only through the chain needs a destination before that. ' + + 'Delete an escalation chain. Refused while a rule tier still names it: detach it with update_alert_rule escalation_chain_id="none" first; a tier that pages only through the chain needs a destination before that (destination_ids on a one-tier rule; the rule\'s thresholds via the v2 API or dashboard on a two-tier rule). ' + "Escalations already running finish. Ids from list_escalation_chains.", parameters: Schema.Struct({ chain_id: P.resourceId("escalationChain", "Escalation chain to delete"), diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index 7c6906f3da..58e0f284dc 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -5524,7 +5524,7 @@ export class AlertsService extends Context.Service Date: Fri, 6 Nov 2026 14:46:56 +0100 Subject: [PATCH 42/98] feat(views): ShiftTimeline, PersonAvatar, PickList and FormScrollArea in the kit The members tab, the destination picker and the SLO and uptime forms move onto them. --- apps/web/src/page/alerts/slo-form/view.ts | 39 +-- apps/web/src/page/alerts/uptime-form/view.ts | 56 ++-- apps/web/src/page/kit/destination-picker.ts | 86 ++---- apps/web/src/page/kit/form-section.ts | 7 + apps/web/src/page/kit/pick-list.ts | 127 ++++++++ apps/web/src/page/settings/members/view.ts | 20 +- packages/views/src/ui/INDEX.md | 5 +- packages/views/src/ui/person-avatar.ts | 44 +++ packages/views/src/ui/shift-timeline.test.ts | 41 +++ packages/views/src/ui/shift-timeline.ts | 302 +++++++++++++++++++ 10 files changed, 597 insertions(+), 130 deletions(-) create mode 100644 apps/web/src/page/kit/pick-list.ts create mode 100644 packages/views/src/ui/person-avatar.ts create mode 100644 packages/views/src/ui/shift-timeline.test.ts create mode 100644 packages/views/src/ui/shift-timeline.ts diff --git a/apps/web/src/page/alerts/slo-form/view.ts b/apps/web/src/page/alerts/slo-form/view.ts index 0c4972a774..08239544ff 100644 --- a/apps/web/src/page/alerts/slo-form/view.ts +++ b/apps/web/src/page/alerts/slo-form/view.ts @@ -20,7 +20,13 @@ import { typography, typographyClass } from "@maple/views/ui/typography" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" import { destinationPicker } from "../../kit/destination-picker" -import { formActionBar, formCard, formSection, managersOnlyPage } from "../../kit/form-section" +import { + formActionBar, + formCard, + formScrollArea, + formSection, + managersOnlyPage, +} from "../../kit/form-section" import { sloHref, slosTabHref } from "../shared/slo-commands" import { formatPercent, formatThreshold, formatWindow } from "../shared/slo-derive" import { budgetDowntime, formIssues, targetOf } from "./derive" @@ -343,23 +349,20 @@ export const view = Submodel.defineView((model, if (isEdit && !AsyncData.isSuccess(model.existing.data) && !AsyncData.isRefreshing(model.existing.data)) return dashboardPage(h, header, [skeleton(h, "h-24 w-full"), skeleton(h, "h-80 w-full")]) return dashboardPage(h, { ...header, fill: true }, [ - h.div( - [h.DataAttribute("slot", "page-scroll-area"), h.Class("min-h-0 flex-1 overflow-auto p-4")], - [ - formCard( - h, - [ - nameSection(h, model.form), - serviceSection(h, model), - indicatorSection(h, model.form), - targetSection(h, model.form), - windowSection(h, model.form), - alertingSection(h, model), - ], - FORM_WIDTH, - ), - ], - ), + formScrollArea(h, [ + formCard( + h, + [ + nameSection(h, model.form), + serviceSection(h, model), + indicatorSection(h, model.form), + targetSection(h, model.form), + windowSection(h, model.form), + alertingSection(h, model), + ], + FORM_WIDTH, + ), + ]), actionBar(h, model), ]) }) diff --git a/apps/web/src/page/alerts/uptime-form/view.ts b/apps/web/src/page/alerts/uptime-form/view.ts index 9b937391a3..2dd7128f3c 100644 --- a/apps/web/src/page/alerts/uptime-form/view.ts +++ b/apps/web/src/page/alerts/uptime-form/view.ts @@ -12,7 +12,13 @@ import { segmentedSelect } from "@maple/views/ui/segmented-select" import { skeleton } from "@maple/views/ui/skeleton" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" -import { formActionBar, formCard, formSection, managersOnlyPage } from "../../kit/form-section" +import { + formActionBar, + formCard, + formScrollArea, + formSection, + managersOnlyPage, +} from "../../kit/form-section" import { failureMessage, type LoadFailure } from "@maple/views/data/load-failure" import { LIMIT_REACHED_CODE, monitorHref, uptimeTabHref } from "../shared/uptime-commands" import { formatMs } from "../shared/uptime-derive" @@ -296,33 +302,27 @@ export const view = Submodel.defineView((model, notifySection(h, model), ]) return dashboardPage(h, { ...header, fill: true }, [ - h.div( - [h.DataAttribute("slot", "page-scroll-area"), h.Class("min-h-0 flex-1 overflow-auto p-4")], - [ - isHttp - ? h.div( - [h.Class(cn("grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]", widthClass))], - [ - card, - h.div([h.Class("lg:sticky lg:top-0 lg:self-start")], [testPanel(h, model)]), - ], - ) - : h.div( - [h.Class(cn(vstackClass({ gap: "2" }), widthClass))], - [ - card, - ...(isEdit - ? [] - : [ - h.p( - [h.Class("px-1 text-xs text-muted-foreground")], - [afterSaveNote(model.form.kind)], - ), - ]), - ], - ), - ], - ), + formScrollArea(h, [ + isHttp + ? h.div( + [h.Class(cn("grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]", widthClass))], + [card, h.div([h.Class("lg:sticky lg:top-0 lg:self-start")], [testPanel(h, model)])], + ) + : h.div( + [h.Class(cn(vstackClass({ gap: "2" }), widthClass))], + [ + card, + ...(isEdit + ? [] + : [ + h.p( + [h.Class("px-1 text-xs text-muted-foreground")], + [afterSaveNote(model.form.kind)], + ), + ]), + ], + ), + ]), actionBar(h, model, widthClass), ]) }) diff --git a/apps/web/src/page/kit/destination-picker.ts b/apps/web/src/page/kit/destination-picker.ts index 51e67080f3..9b684397c8 100644 --- a/apps/web/src/page/kit/destination-picker.ts +++ b/apps/web/src/page/kit/destination-picker.ts @@ -2,13 +2,9 @@ import type { AlertDestinationRow } from "@maple/web-data/collections/alerts" import { Array as Arr, Option } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { icon } from "@maple/views/ui/icon" -import { iconButton } from "@maple/views/ui/icon-button" import type * as Menu from "@maple/views/ui/menu" -import * as MenuView from "@maple/views/ui/menu-view" -import { panel } from "@maple/views/ui/panel" -import { pressable } from "@maple/views/ui/pressable" -import { hstack } from "@maple/views/ui/stack" -import { typography } from "@maple/views/ui/typography" +import type * as MenuView from "@maple/views/ui/menu-view" +import { pickList } from "./pick-list" /** * `DestinationPicker`: the alert destinations a form notifies, each removable, then an "Add @@ -41,66 +37,24 @@ export const destinationPicker = ( ), })) const available = Arr.filter(rows, (row) => !Arr.some(options.selectedIds, (id) => id === row.id)) - return panel(h, { className: "divide-y shadow-none" }, [ - ...Arr.map(selected, (destination) => - hstack(h, { gap: "2", className: "px-3 py-2", attributes: [h.Key(destination.id)] }, [ - icon(h, "BellIcon", { className: "text-muted-foreground" }), - typography(h, { size: "sm", truncate: true, className: "min-w-0 flex-1" }, [ - destination.name, - ]), - iconButton( - h, - { - label: `Stop notifying ${destination.name}`, - className: "text-muted-foreground", - attributes: [h.OnClick(options.onRemove(destination.id))], - }, - [icon(h, "XmarkIcon")], - ), - ]), - ), - ...(selected.length === 0 - ? [ - typography(h, { size: "sm", tone: "muted", as: "p", className: "px-3 py-2.5" }, [ - options.emptyText, - ]), - ] - : []), - ...(available.length === 0 - ? [] - : [ - h.submodel({ - slotId: options.menu.id, - model: options.menu, - view: MenuView.view, - viewInputs: { - entries: Arr.map(available, (row): MenuView.MenuEntry => ({ - _tag: "Item", - value: row.id, - label: row.name, - })), - align: "start", - className: "w-72", - toView: ({ trigger, withTrigger }) => - h.span( - [h.Class("contents")], - withTrigger( - pressable( - h, - { - className: - "flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-muted-foreground hover:bg-surface-sunken active:bg-surface-sunken hover:text-foreground active:text-foreground", - attributes: trigger, - }, - [icon(h, "PlusIcon", { size: 14 }), "Add destination"], - ), - ), - ), - }, - toParentMessage: options.toMenuMessage, - }), - ]), - ]) + return pickList(h, { + entries: Arr.map(selected, (destination) => ({ + key: destination.id, + label: destination.name, + media: icon(h, "BellIcon", { className: "text-muted-foreground" }), + removeLabel: `Stop notifying ${destination.name}`, + onRemove: options.onRemove(destination.id), + })), + emptyText: options.emptyText, + menu: options.menu, + toMenuMessage: options.toMenuMessage, + addEntries: Arr.map(available, (row): MenuView.MenuEntry => ({ + _tag: "Item", + value: row.id, + label: row.name, + })), + addLabel: "Add destination", + }) } /** The destination a menu pick added, if it is a synced one not already chosen. */ diff --git a/apps/web/src/page/kit/form-section.ts b/apps/web/src/page/kit/form-section.ts index 18e8771cb6..56b0edae15 100644 --- a/apps/web/src/page/kit/form-section.ts +++ b/apps/web/src/page/kit/form-section.ts @@ -45,6 +45,13 @@ export const formSection = ( ], ) +/** `FormScrollArea`: a form page's scroller above its action bar, the page's own scroll area. */ +export const formScrollArea = (h: HtmlBuilder, children: ReadonlyArray): Html => + h.div( + [h.DataAttribute("slot", "page-scroll-area"), h.Class("min-h-0 flex-1 overflow-auto p-4")], + children, + ) + /** `FormCard`: the bordered card a form's sections sit in, ruled between them. */ export const formCard = (h: HtmlBuilder, children: ReadonlyArray, className?: string): Html => h.div([h.Class(cn("divide-y rounded-md border bg-card", className))], children) diff --git a/apps/web/src/page/kit/pick-list.ts b/apps/web/src/page/kit/pick-list.ts new file mode 100644 index 0000000000..962aa4ca9f --- /dev/null +++ b/apps/web/src/page/kit/pick-list.ts @@ -0,0 +1,127 @@ +import { Array as Arr } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" +import type * as Menu from "@maple/views/ui/menu" +import * as MenuView from "@maple/views/ui/menu-view" +import { panel } from "@maple/views/ui/panel" +import { pressable } from "@maple/views/ui/pressable" +import { hstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" + +// `PickList`: what a form has picked (destinations, people, schedules), each removable and, in an +// ordered list, movable, then an "Add" menu of the rest. + +/** One picked entry: its media (an icon or an avatar), its name and how to remove or move it. */ +export type PickedEntry = Readonly<{ + key: string + label: string + media: Html + /** A muted note after the name ("on call: Ben Brook"). */ + detail?: string + removeLabel: string + onRemove: M + /** Present on an ordered list: the up and down buttons, disabled at its ends. */ + move?: Readonly<{ up: M | null; down: M | null }> +}> + +/** + * `PickList`: what a form has picked, each removable (and in order, movable), then an "Add" menu + * of the rest. The owner keeps the menu's Model and the picks; destinations, people and schedules. + */ +export const pickList = ( + h: HtmlBuilder, + options: Readonly<{ + entries: ReadonlyArray> + /** What an empty pick means, in one line. */ + emptyText: string + menu: Menu.Model + toMenuMessage: (message: Menu.Message) => M + /** The menu's entries; no add row while there are none. */ + addEntries: ReadonlyArray + addLabel: string + }>, +): Html => + panel(h, { className: "divide-y shadow-none" }, [ + ...Arr.map(options.entries, (entry) => + hstack(h, { gap: "2", className: "px-3 py-2", attributes: [h.Key(entry.key)] }, [ + entry.media, + typography(h, { size: "sm", truncate: true, className: "min-w-0 flex-1" }, [ + entry.label, + ...(entry.detail === undefined + ? [] + : [typography(h, { size: "xs", tone: "muted" }, [` · ${entry.detail}`])]), + ]), + ...(entry.move === undefined + ? [] + : [ + iconButton( + h, + { + label: `Move ${entry.label} up`, + isDisabled: entry.move.up === null, + className: "text-muted-foreground", + attributes: entry.move.up === null ? [] : [h.OnClick(entry.move.up)], + }, + [icon(h, "ArrowUpIcon")], + ), + iconButton( + h, + { + label: `Move ${entry.label} down`, + isDisabled: entry.move.down === null, + className: "text-muted-foreground", + attributes: entry.move.down === null ? [] : [h.OnClick(entry.move.down)], + }, + [icon(h, "ArrowDownIcon")], + ), + ]), + iconButton( + h, + { + label: entry.removeLabel, + className: "text-muted-foreground", + attributes: [h.OnClick(entry.onRemove)], + }, + [icon(h, "XmarkIcon")], + ), + ]), + ), + ...(options.entries.length === 0 + ? [ + typography(h, { size: "sm", tone: "muted", as: "p", className: "px-3 py-2.5" }, [ + options.emptyText, + ]), + ] + : []), + ...(options.addEntries.length === 0 + ? [] + : [ + h.submodel({ + slotId: options.menu.id, + model: options.menu, + view: MenuView.view, + viewInputs: { + entries: options.addEntries, + align: "start", + className: "w-72", + toView: ({ trigger, withTrigger }) => + h.span( + [h.Class("contents")], + withTrigger( + pressable( + h, + { + className: + "flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-muted-foreground hover:bg-surface-sunken active:bg-surface-sunken hover:text-foreground active:text-foreground", + attributes: trigger, + }, + [icon(h, "PlusIcon", { size: 14 }), options.addLabel], + ), + ), + ), + }, + toParentMessage: options.toMenuMessage, + }), + ]), + ]) diff --git a/apps/web/src/page/settings/members/view.ts b/apps/web/src/page/settings/members/view.ts index 8f46207ab9..a0f7129304 100644 --- a/apps/web/src/page/settings/members/view.ts +++ b/apps/web/src/page/settings/members/view.ts @@ -1,5 +1,4 @@ import * as Policy from "@maple/domain/policy" -import { initialsFrom } from "@maple/ui/lib/initials" import { formatRelativeTime } from "@maple/ui/lib/time-format" import { cn } from "@maple/ui/lib/utils" import { orgRoleLabel, roleChangePrompt, toOrgRole } from "../../../components/settings/org-membership" @@ -8,8 +7,7 @@ import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { button } from "@maple/views/ui/button" import { confirmDialog } from "@maple/views/ui/confirm-dialog" -import { warmAvatarTone } from "@maple/views/ui/avatar-tone" -import { avatarFallback } from "@maple/views/ui/display" +import { personAvatar } from "@maple/views/ui/person-avatar" import { field, type FieldIds, fieldLabel, input, type FieldState } from "@maple/views/ui/field" import * as Field from "@maple/views/ui/field" import { formDialog } from "@maple/views/ui/form-dialog" @@ -123,7 +121,7 @@ const memberRows = (h: H, model: Model, member: MemberRow, inputs: Inputs): Read [ truncatedTableCell(h, [ hstack(h, { gap: "3" }, [ - personAvatar(h, member.name, member.imageUrl), + personAvatar(h, { name: member.name, imageUrl: member.imageUrl }), h.div( [h.Class("min-w-0")], [ @@ -247,18 +245,6 @@ const memberRows = (h: H, model: Model, member: MemberRow, inputs: Inputs): Read ] } -/** The warm initials tile the sidebar draws, or the member's own uploaded image. */ -const personAvatar = (h: H, seed: string, imageUrl: string): Html => - avatarFallback( - h, - { - className: "size-6 shrink-0", - fallbackClassName: `font-medium ${warmAvatarTone(seed)}`, - imageUrl, - }, - initialsFrom(seed), - ) - export const description = "Admins manage billing, keys and members." /** The tab's primary action, drawn in the settings page header beside the `

`. */ @@ -316,7 +302,7 @@ const invitationRow = (h: H, model: Model, invitation: InvitationRow, inputs: In [ truncatedTableCell(h, [ hstack(h, { gap: "3" }, [ - personAvatar(h, invitation.email, ""), + personAvatar(h, { name: invitation.email }), truncatedText(h, { text: invitation.email, className: "text-xs" }), ]), ]), diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 53137c3e47..3efe95edd5 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -122,6 +122,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/overlay/transition.ts` `isLeaving`, `isMounted`, `stateAttributes`, `focusGuard`: Base UI's transition status on a Foldkit Animation: `data-starting-style` on the first frame of an open, `data-ending-style` while it closes, and `data-open` / `data-closed` thr... - `packages/views/src/ui/page-layout.ts` `pageLayoutRoot`, `pageTitle`, `pageDescription`, `pageHeader`, `pageHeaderActions`: Port of the header half of packages/ui/src/components/ui/page-layout.tsx (Root, Header, Title, Description, HeaderActions). - `packages/views/src/ui/panel.ts` `panel`, `panelTitle`, `panelHeader`, `panelBody`, `panelChartBody`: Port of packages/ui/src/components/ui/panel.tsx: the flat bordered section frame. +- `packages/views/src/ui/person-avatar.ts` `personAvatar`, `personLabel`: `PersonAvatar` and `PersonLabel`: a member of the org as a warm initials tile (or their own image), alone or beside their name. - `packages/views/src/ui/popover.ts` `titleId`, `descriptionId`, `open`, `close`, `view`, `popoverTitle`, `popoverDescription` (stateful: Model, init, update): Base UI `Popover` (`@maple/ui/popover`, non-modal): the trigger toggles it, Escape and a press outside close it, and it is placed under the trigger with `sideOffset` 4 and colli... - `packages/views/src/ui/pressable.ts` `pressable`: `Pressable`: a click target that draws its own look (a timeline lane, a chart bar, a colour swatch, a bespoke row). - `packages/views/src/ui/radio-group.ts` `radioGroup`, `labelId`, `radio` (stateful: Model, init, update): Base UI `RadioGroup`: the checked value. @@ -140,6 +141,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/selectable-row.ts` `selectableRow`: `SelectableRow`: a bespoke row's frame (a `div`, or a link with `href`) with `listRow`'s selection lane and tint; a clickable one is a `pressable` with `selectedLane`. - `packages/views/src/ui/setting-row.ts` `settingRow`: Port of packages/ui/src/components/ui/setting-row.tsx. - `packages/views/src/ui/sheet.ts` `leftSheet`: `` (packages/ui/src/components/ui/sheet.tsx) over Base UI Dialog. +- `packages/views/src/ui/shift-timeline.ts` `zonedDayStart`, `placeBar`, `timelineDays`, `shiftTimeline`: `ShiftTimeline`: rows of time bars over a day-ruled axis in one time zone, with a now line (an on-call schedule's layers and its final row). - `packages/views/src/ui/side-panel.ts` `sidePanelFrame`: A detail side panel's frame (span, service, database): a full-height clipped column on the page background. - `packages/views/src/ui/sidebar.ts` `sidebarWrapper`, `sidebarDesktop`, `sidebarMobile`, `sidebarHeader`, `sidebarFooter`, `sidebarContent`, `sidebarGroup`, `sidebarGroupLabel`, `sidebarGroupContent`, `sidebarGroupAction`, `sidebarSeparator`, `sidebarMenu`, `sidebarMenuItem`, `sidebarMenuBadge`, `sidebarMenuSub`, `sidebarMenuSubItem`, `isLinkActive`, `sidebarMenuButton`, `sidebarMenuSubButton`, `sidebarRail`, `sidebarInset`, `sidebarTrigger`: The Foldkit side of packages/ui/src/components/ui/sidebar.tsx: same markup, same class composition. - `packages/views/src/ui/signal-empty-state.ts` `emptyActions`, `filteredEmpty`, `presenceLoading`, `signalEmptyState`: `SignalEmptyState` and `FilteredEmpty`. @@ -184,7 +186,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/dashboard-page.ts` `pageBody`, `filterRail`, `pageContent`, `pageHeader`, `pageScrollArea`, `dashboardPage`: The dashboard page below the shell's top bar: `PageLayout.Body` with its filter rail, content column, sticky header and scroll area. - `apps/web/src/page/kit/destination-picker.ts` `destinationPicker`, `pickedDestination`: `DestinationPicker`: the alert destinations a form notifies, each removable, then an "Add destination" menu of the rest; the owner keeps the menu's Model and the chosen ids. - `apps/web/src/page/kit/detail-header.ts` `detailEyebrow`, `detailTitle`, `detailTitleSkeleton`, `detailActions`: `DetailHeader`'s title side and `DetailHeaderSkeleton`. -- `apps/web/src/page/kit/form-section.ts` `formSection`, `formCard`, `formActionBar`, `managersOnlyPage`: `FormSection`: one row of a form card, what it sets on the left (a title and an optional hint) and its controls on the right; the two stack on phones. +- `apps/web/src/page/kit/form-section.ts` `formSection`, `formScrollArea`, `formCard`, `formActionBar`, `managersOnlyPage`: `FormSection`: one row of a form card, what it sets on the left (a title and an optional hint) and its controls on the right; the two stack on phones. - `apps/web/src/page/kit/guided-setup.ts` `frameworkIconStrip`, `frameworkPicker`, `agentPromptButton`, `connectInstructions`: `FrameworkIconStrip`, `FrameworkPicker` and `ConnectInstructions`, with `PackageManagerCodeBlock`. - `apps/web/src/page/kit/infra-correlation/command.ts` `operationOf`: The four `*InfraTimeseries` reads the panel's charts make, through the read cache under the atom key (`requestKey(org, "", { data })`), so a reopened panel reuses them. - `apps/web/src/page/kit/infra-correlation/update.ts` `chartIdOf`, `requestsOf` (stateful: Model, init, update): A chart's slot and chart id, `-` (the linked-cursor id). @@ -198,6 +200,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/message-response.ts` `glyph`, `messageResponse`: Lightweight markdown over Streamdown: laid out with Streamdown's element classes. - `apps/web/src/page/kit/page-rail-sheet.ts` `pageRailSheet`: `PageLayout.FilterSidebar` / `RightSidebar` below `lg`: the page's rail in a side sheet (``) with an sr-only header. - `apps/web/src/page/kit/peek-keys.ts` `peekStepFor`: The list peeks' step keys: down/J forward, up/K back, except in a text field or with a modifier. +- `apps/web/src/page/kit/pick-list.ts` `pickList`: `PickList`: what a form has picked (destinations, people, schedules), each removable and, in an ordered list, movable, then an "Add" menu of the rest. - `apps/web/src/page/kit/pinned-namespace-notice.ts` `pinnedNamespaceNotice`: `PinnedNamespaceNotice`: replaces a rail's Namespace section while the org-global pin is on. - `apps/web/src/page/kit/query-builder/change.ts` `withRootOnly`, `applyQueryChange`: One edit a `QueryPanel` makes to its draft (its `onUpdate`, `onAggregationChange` and `onDataSourceChange` callbacks), as data the host applies. - `apps/web/src/page/kit/query-builder/draft.ts` `groupBySelectionOf`: `QueryBuilderQueryDraft` (@maple/query-engine/query-builder) as a Schema: the query one `QueryPanel` edits, shared by the widget editor and the alert rule editor. diff --git a/packages/views/src/ui/person-avatar.ts b/packages/views/src/ui/person-avatar.ts new file mode 100644 index 0000000000..00cc8926d6 --- /dev/null +++ b/packages/views/src/ui/person-avatar.ts @@ -0,0 +1,44 @@ +import { initialsFrom } from "@maple/ui/lib/initials" +import { cn } from "@maple/ui/lib/utils" +import type { Html, HtmlBuilder } from "foldkit/html" +import { warmAvatarTone } from "./avatar-tone" +import { avatarFallback } from "./display" +import { hstack, vstack } from "./stack" +import { typography } from "./typography" + +// `PersonAvatar` and `PersonLabel`: a member of the org as a warm initials tile (or their own +// image), alone or beside their name. + +const SIZE = { xs: "size-5", sm: "size-6" } as const + +/** A member's avatar: their uploaded image, else the warm initials tile seeded by their name. */ +export const personAvatar = ( + h: HtmlBuilder, + options: Readonly<{ name: string; imageUrl?: string; size?: keyof typeof SIZE; className?: string }>, +): Html => + avatarFallback( + h, + { + className: cn(SIZE[options.size ?? "sm"], "shrink-0", options.className), + fallbackClassName: `font-medium ${warmAvatarTone(options.name)}`, + imageUrl: options.imageUrl ?? "", + }, + initialsFrom(options.name), + ) + +/** Avatar, name and an optional muted line under it ("until Mon 09:00"), truncating in a row. */ +export const personLabel = ( + h: HtmlBuilder, + options: Readonly<{ name: string; imageUrl?: string; detail?: string; size?: keyof typeof SIZE }>, +): Html => + hstack(h, { gap: "2", shrink: true }, [ + personAvatar(h, { name: options.name, imageUrl: options.imageUrl, size: options.size }), + options.detail === undefined + ? typography(h, { size: "sm", truncate: true }, [options.name]) + : vstack(h, { gap: "0", shrink: true }, [ + typography(h, { size: "sm", truncate: true }, [options.name]), + typography(h, { size: "xs", tone: "muted", truncate: true, numeric: true }, [ + options.detail, + ]), + ]), + ]) diff --git a/packages/views/src/ui/shift-timeline.test.ts b/packages/views/src/ui/shift-timeline.test.ts new file mode 100644 index 0000000000..15eb3bb6a4 --- /dev/null +++ b/packages/views/src/ui/shift-timeline.test.ts @@ -0,0 +1,41 @@ +import { Option, Array as Arr } from "effect" +import { describe, expect, test } from "vitest" +import { placeBar, timelineDays, zonedDayStart } from "./shift-timeline" + +const HOUR = 3_600_000 + +describe("shift timeline layout", () => { + test("a bar is placed as a share of the range and clipped to it", () => { + expect(placeBar(0, 100, 25, 50)).toEqual(Option.some({ leftPct: 25, widthPct: 25 })) + expect(placeBar(0, 100, -50, 10)).toEqual(Option.some({ leftPct: 0, widthPct: 10 })) + expect(placeBar(0, 100, 100, 150)).toEqual(Option.none()) + }) + + test("days follow the zone's wall clock: the Berlin fall-back Sunday is 25 hours wide", () => { + const from = Date.parse("2026-10-22T22:00:00.000Z") // Fri 23 Oct, 00:00 in Berlin + const to = from + 7 * 24 * HOUR + HOUR + const days = timelineDays(from, to, "Europe/Berlin") + expect(Arr.map(days, (day) => `${day.weekday} ${day.dayOfMonth}`)).toEqual([ + "Fri 23", + "Sat 24", + "Sun 25", + "Mon 26", + "Tue 27", + "Wed 28", + "Thu 29", + ]) + const sunday = days[2] + expect(sunday === undefined ? 0 : (sunday.endMs - sunday.startMs) / HOUR).toBe(25) + expect(Arr.reduce(days, 0, (sum, day) => sum + day.widthPct)).toBeCloseTo(100, 6) + }) + + test("a day starts at the zone's midnight, not UTC's", () => { + const noonInNewYork = Date.parse("2026-11-10T17:00:00.000Z") + expect(new Date(zonedDayStart(noonInNewYork, "America/New_York")).toISOString()).toBe( + "2026-11-10T05:00:00.000Z", + ) + expect(new Date(zonedDayStart(noonInNewYork, "America/New_York", 1)).toISOString()).toBe( + "2026-11-11T05:00:00.000Z", + ) + }) +}) diff --git a/packages/views/src/ui/shift-timeline.ts b/packages/views/src/ui/shift-timeline.ts new file mode 100644 index 0000000000..a4c7846b1e --- /dev/null +++ b/packages/views/src/ui/shift-timeline.ts @@ -0,0 +1,302 @@ +import { zonedDateParts, zonedPartsToEpochMs } from "@maple/query-engine/datetime" +import { cn } from "@maple/ui/lib/utils" +import { Array as Arr, Option } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { pressable } from "./pressable" +import { hstack, vstack } from "./stack" +import { toneBoxClass } from "./tone-box" +import { tooltipTrigger } from "./tooltip-host" +import { typography } from "./typography" + +// `ShiftTimeline`: rows of time bars over a day-ruled axis in one time zone, with a now line (an +// on-call schedule's layers and its final row). Days follow the zone's wall clock, so a DST day is +// 23 or 25 hours wide. + +/** A bar's look: a layer's shift, the effective shift, or an override over it. */ +export type TimelineBarTone = "neutral" | "primary" | "info" + +export type TimelineBar = Readonly<{ + key: string + startMs: number + endMs: number + label: string + /** The tooltip and accessible name: who, and from when to when. */ + title: string + tone: TimelineBarTone + onClick?: M +}> + +export type TimelineRow = Readonly<{ + key: string + label: string + detail?: string + bars: ReadonlyArray> + /** What an empty row says, for a row with no bars in the range. */ + emptyText?: string +}> + +/** One wall-clock day of the axis, as a share of the range. */ +export type TimelineDay = Readonly<{ + startMs: number + endMs: number + weekday: string + dayOfMonth: number + leftPct: number + widthPct: number +}> + +const weekdayFormatters = new Map() +const weekdayOf = (ms: number, timeZone: string): string => { + const cached = weekdayFormatters.get(timeZone) + const formatter = cached ?? new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short" }) + if (cached === undefined) weekdayFormatters.set(timeZone, formatter) + return formatter.format(ms) +} + +/** The instant the wall-clock day containing `ms` starts in `timeZone`, `offsetDays` later. */ +export const zonedDayStart = (ms: number, timeZone: string, offsetDays = 0): number => { + const parts = zonedDateParts(ms, timeZone) + return zonedPartsToEpochMs( + { ...parts, day: parts.day + offsetDays, hour: 0, minute: 0, second: 0 }, + timeZone, + ) +} + +/** Where `[startMs, endMs)` sits in the range, clipped to it; none when it falls outside. */ +export const placeBar = ( + fromMs: number, + toMs: number, + startMs: number, + endMs: number, +): Option.Option> => { + const span = toMs - fromMs + const start = Math.max(startMs, fromMs) + const end = Math.min(endMs, toMs) + return span <= 0 || end <= start + ? Option.none() + : Option.some({ leftPct: ((start - fromMs) / span) * 100, widthPct: ((end - start) / span) * 100 }) +} + +/** The wall-clock days the range covers in `timeZone`, each clipped to the range. */ +export const timelineDays = (fromMs: number, toMs: number, timeZone: string): ReadonlyArray => { + const count = Math.ceil((toMs - fromMs) / 82_800_000) + 1 + return Arr.flatMap(Arr.range(0, count), (offset): ReadonlyArray => { + const startMs = zonedDayStart(fromMs, timeZone, offset) + const endMs = zonedDayStart(fromMs, timeZone, offset + 1) + return Option.match(placeBar(fromMs, toMs, startMs, endMs), { + onNone: () => [], + onSome: (place) => [ + { + startMs, + endMs, + weekday: weekdayOf(Math.max(startMs, fromMs), timeZone), + dayOfMonth: zonedDateParts(Math.max(startMs, fromMs), timeZone).day, + ...place, + }, + ], + }) + }) +} + +const BAR_TONE = { + neutral: "bg-surface-strong border-border text-foreground", + primary: toneBoxClass("primary"), + info: toneBoxClass("info"), +} as const satisfies Record + +const BAR_CLASS = "absolute inset-y-1 overflow-hidden rounded-sm border px-1.5 text-left" + +const barView = ( + h: HtmlBuilder, + fromMs: number, + toMs: number, + bar: TimelineBar, +): ReadonlyArray => + Option.match(placeBar(fromMs, toMs, bar.startMs, bar.endMs), { + onNone: () => [], + onSome: ({ leftPct, widthPct }) => { + // A 2px gutter keeps back-to-back shifts apart. + const style = h.Style({ left: `${leftPct}%`, width: `calc(${widthPct}% - 2px)` }) + const text = typography(h, { size: "xs", truncate: true, className: "block leading-6" }, [ + bar.label, + ]) + const attributes = [h.Key(bar.key), style, ...tooltipTrigger(h, bar.title)] + return [ + bar.onClick === undefined + ? h.div( + [ + ...attributes, + h.Class(cn(BAR_CLASS, BAR_TONE[bar.tone])), + h.AriaLabel(bar.title), + ], + [text], + ) + : pressable( + h, + { + onClick: bar.onClick, + label: bar.title, + className: cn( + BAR_CLASS, + BAR_TONE[bar.tone], + "hover:border-ring active:border-ring", + ), + attributes, + }, + [text], + ), + ] + }, + }) + +const LABEL_COLUMN = "sticky left-0 z-10 w-32 shrink-0 border-r bg-background px-3 sm:w-40" + +/** + * The timeline: a header of days, then one row per lane (label column, bars track), ruled by day, + * with a now line when `nowMs` is inside the range. Scrolls sideways under a phone's width. + */ +export const shiftTimeline = ( + h: HtmlBuilder, + options: Readonly<{ + fromMs: number + toMs: number + timeZone: string + nowMs: number + rows: ReadonlyArray> + ariaLabel: string + /** A key under the timeline for the bar tones it uses. */ + legend?: ReadonlyArray> + }>, +): Html => { + const { fromMs, toMs } = options + const days = timelineDays(fromMs, toMs, options.timeZone) + const now = placeBar(fromMs, toMs, options.nowMs, options.nowMs + 1) + const rules = h.div( + [h.AriaHidden(true), h.Class("pointer-events-none absolute inset-0")], + Arr.map(days, (day) => + h.div( + [ + h.Key(`rule-${day.startMs}`), + h.Class("absolute inset-y-0 border-l border-border-subtle"), + h.Style({ left: `${day.leftPct}%` }), + ], + [], + ), + ), + ) + const nowLine = Option.match(now, { + onNone: () => [], + onSome: ({ leftPct }) => [ + h.div( + [ + h.Key("now"), + h.DataAttribute("slot", "timeline-now"), + h.AriaLabel("Now"), + h.Class("pointer-events-none absolute inset-y-0 w-px bg-primary"), + h.Style({ left: `${leftPct}%` }), + ], + [], + ), + ], + }) + const header = h.div( + [h.Class("flex border-b")], + [ + h.div([h.Class(LABEL_COLUMN)], []), + h.div( + [h.Class("relative h-8 grow")], + Arr.map(days, (day) => + h.div( + [ + h.Key(`day-${day.startMs}`), + h.Class( + "absolute inset-y-0 overflow-hidden border-l border-border-subtle px-1.5 py-1.5", + ), + h.Style({ left: `${day.leftPct}%`, width: `${day.widthPct}%` }), + ], + [ + typography( + h, + { + size: "xs", + tone: "muted", + numeric: true, + truncate: true, + className: "block", + }, + [`${day.weekday} ${day.dayOfMonth}`], + ), + ], + ), + ), + ), + ], + ) + const rowView = (row: TimelineRow): Html => + h.div( + [h.Key(row.key), h.Class("flex border-b last:border-b-0"), h.Role("row")], + [ + vstack( + h, + { + gap: "0", + justify: "center", + className: cn(LABEL_COLUMN, "py-1.5"), + attributes: [h.Role("rowheader")], + }, + [ + typography(h, { size: "xs", weight: "medium", truncate: true }, [row.label]), + ...(row.detail === undefined + ? [] + : [typography(h, { size: "2xs", tone: "muted", truncate: true }, [row.detail])]), + ], + ), + h.div( + [h.Class("relative h-8 grow"), h.Role("cell")], + row.bars.length === 0 && row.emptyText !== undefined + ? [ + typography( + h, + { size: "xs", tone: "faint", className: "block px-2 leading-8" }, + [row.emptyText], + ), + ] + : Arr.flatMap(row.bars, (bar) => barView(h, fromMs, toMs, bar)), + ), + ], + ) + const legend = Arr.map(options.legend ?? [], (entry) => + hstack(h, { gap: "1.5", as: "span", attributes: [h.Key(entry.label)] }, [ + h.span([h.AriaHidden(true), h.Class(cn("size-3 rounded-xs border", BAR_TONE[entry.tone]))], []), + typography(h, { size: "xs", tone: "muted" }, [entry.label]), + ]), + ) + const frame = h.div( + [h.Class("overflow-x-auto rounded-md border"), h.DataAttribute("slot", "shift-timeline")], + [ + h.div( + [h.Class("relative min-w-[720px]"), h.Role("table"), h.AriaLabel(options.ariaLabel)], + [ + header, + h.div( + [h.Class("relative")], + [ + h.div( + [ + h.Class( + "pointer-events-none absolute inset-y-0 right-0 left-32 sm:left-40", + ), + ], + [rules, ...nowLine], + ), + ...Arr.map(options.rows, rowView), + ], + ), + ], + ), + ], + ) + return legend.length === 0 + ? frame + : vstack(h, { gap: "2" }, [frame, hstack(h, { gap: "4", wrap: true }, legend)]) +} From 2f97969303d7cde6494d7ded537b34af7577c061 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:46:57 +0100 Subject: [PATCH 43/98] feat(alerts): on-call schedules in the hub, with a schedule form, a calendar and overrides An On-call tab lists each schedule with who is on call and the next handoff, the inbox opens with who is on call now, and chain steps page people and schedules as well as destinations. --- .../alerts/escalation-dialog/dialog.test.ts | 42 +- .../page/alerts/escalation-dialog/dialog.ts | 85 ++- .../src/page/alerts/escalation-dialog/view.ts | 142 ++++- apps/web/src/page/alerts/hub/command.ts | 35 +- apps/web/src/page/alerts/hub/derive.ts | 4 +- .../page/alerts/hub/hub-chains.scene.test.ts | 2 +- .../page/alerts/hub/hub-oncall.scene.test.ts | 173 ++++++ apps/web/src/page/alerts/hub/message.ts | 14 +- apps/web/src/page/alerts/hub/model.ts | 18 +- apps/web/src/page/alerts/hub/update.ts | 203 ++++++- apps/web/src/page/alerts/hub/view-chains.ts | 16 +- apps/web/src/page/alerts/hub/view-oncall.ts | 286 ++++++++++ apps/web/src/page/alerts/hub/view.ts | 46 +- .../src/page/alerts/schedule-form/command.ts | 53 ++ .../web/src/page/alerts/schedule-form/form.ts | 304 ++++++++++ .../src/page/alerts/schedule-form/index.ts | 23 + .../src/page/alerts/schedule-form/message.ts | 50 ++ .../src/page/alerts/schedule-form/model.ts | 42 ++ .../schedule-form/schedule-form.scene.test.ts | 151 +++++ .../src/page/alerts/schedule-form/update.ts | 295 ++++++++++ .../web/src/page/alerts/schedule-form/view.ts | 529 ++++++++++++++++++ apps/web/src/page/alerts/schedule/command.ts | 84 +++ apps/web/src/page/alerts/schedule/index.ts | 39 ++ apps/web/src/page/alerts/schedule/message.ts | 35 ++ apps/web/src/page/alerts/schedule/model.ts | 44 ++ .../alerts/schedule/schedule.scene.test.ts | 159 ++++++ apps/web/src/page/alerts/schedule/update.ts | 322 +++++++++++ apps/web/src/page/alerts/schedule/view.ts | 421 ++++++++++++++ .../alerts/shared/escalation-chains.test.ts | 18 + .../page/alerts/shared/escalation-chains.ts | 24 +- .../page/alerts/shared/oncall-schedules.ts | 141 +++++ .../alerts/shared/oncall-test-fixtures.ts | 458 +++++++++++++++ .../web/src/page/alerts/shared/org-members.ts | 67 +++ apps/web/src/page/registry.ts | 4 + apps/web/src/route/app-route.ts | 2 + apps/web/src/route/routers.ts | 13 + apps/web/src/route/search.ts | 7 + 37 files changed, 4280 insertions(+), 71 deletions(-) create mode 100644 apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts create mode 100644 apps/web/src/page/alerts/hub/view-oncall.ts create mode 100644 apps/web/src/page/alerts/schedule-form/command.ts create mode 100644 apps/web/src/page/alerts/schedule-form/form.ts create mode 100644 apps/web/src/page/alerts/schedule-form/index.ts create mode 100644 apps/web/src/page/alerts/schedule-form/message.ts create mode 100644 apps/web/src/page/alerts/schedule-form/model.ts create mode 100644 apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts create mode 100644 apps/web/src/page/alerts/schedule-form/update.ts create mode 100644 apps/web/src/page/alerts/schedule-form/view.ts create mode 100644 apps/web/src/page/alerts/schedule/command.ts create mode 100644 apps/web/src/page/alerts/schedule/index.ts create mode 100644 apps/web/src/page/alerts/schedule/message.ts create mode 100644 apps/web/src/page/alerts/schedule/model.ts create mode 100644 apps/web/src/page/alerts/schedule/schedule.scene.test.ts create mode 100644 apps/web/src/page/alerts/schedule/update.ts create mode 100644 apps/web/src/page/alerts/schedule/view.ts create mode 100644 apps/web/src/page/alerts/shared/oncall-schedules.ts create mode 100644 apps/web/src/page/alerts/shared/oncall-test-fixtures.ts create mode 100644 apps/web/src/page/alerts/shared/org-members.ts diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts index d3ff247bb9..7212c7ef90 100644 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts +++ b/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts @@ -1,7 +1,9 @@ import { Option, Array as Arr, Schema } from "effect" import { AlertDestinationId } from "@maple/domain/http" +import * as Menu from "@maple/views/ui/menu" import { describe, expect, it } from "vitest" import { chainDestinationUuid, chainOf } from "../shared/escalation-test-fixtures" +import { memberId, mixedTargetChain, scheduleUuid } from "../shared/oncall-test-fixtures" import { init, Message, type Model, openEdit, payloadOf, update } from "./dialog" const asDestination = Schema.decodeSync(AlertDestinationId) @@ -42,6 +44,39 @@ describe("escalation chain dialog", () => { ) }) + it("a chain built through the API keeps its people and schedules through an edit", () => { + const chain = mixedTargetChain(chainDestinationUuid(1)) + const model = openEdit(init("chain"), chain).model + expect(Option.map(payloadOf(model), (payload) => payload.steps)).toEqual(Option.some(chain.steps)) + const key = model.steps[1]?.key ?? "" + const removed = run(model, [ + Message.ClickedRemoveTarget({ key, target: `schedule:${scheduleUuid(1)}` }), + ]) + expect(Option.map(payloadOf(removed), (payload) => payload.steps[1]?.targets)).toEqual( + Option.some([{ type: "user", user_id: memberId(1) }]), + ) + }) + + it("adds a person or a schedule from the step's menu, once each", () => { + const model = opened() + const key = model.steps[0]?.key ?? "" + const pick = (value: string) => + Message.GotStepMenuMessage({ + key, + message: Menu.Message.ClickedItem({ index: 0, item: { kind: "Item", value } }), + }) + const added = run(model, [ + pick(`user:${memberId(3)}`), + pick(`schedule:${scheduleUuid(2)}`), + pick(`user:${memberId(3)}`), + ]) + expect(added.steps[0]?.targets).toEqual([ + { type: "destination", destination_id: chainDestinationUuid(1) }, + { type: "user", user_id: memberId(3) }, + { type: "schedule", schedule_id: scheduleUuid(2) }, + ]) + }) + it("holds the API's limits: a name, a destination per step, a 1 to 1440 minute wait, 0 to 5 repeats", () => { const model = opened() const key = model.steps[0]?.key ?? "" @@ -52,7 +87,12 @@ describe("escalation chain dialog", () => { expect(broken([Message.ChangedWait({ key, value: "2.5" })])).toBe(true) expect(broken([Message.ChangedRepeatCount({ value: "6" })])).toBe(true) expect( - broken([Message.ClickedRemoveDestination({ key, id: asDestination(chainDestinationUuid(1)) })]), + broken([ + Message.ClickedRemoveTarget({ + key, + target: `destination:${asDestination(chainDestinationUuid(1))}`, + }), + ]), ).toBe(true) expect( broken([Message.ChangedWait({ key, value: "1440" }), Message.ChangedRepeatCount({ value: "5" })]), diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.ts index 37dd74ee65..502736d839 100644 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.ts +++ b/apps/web/src/page/alerts/escalation-dialog/dialog.ts @@ -9,11 +9,14 @@ import { MAX_ESCALATION_STEPS, MAX_ESCALATION_STEP_TARGETS, MAX_ESCALATION_WAIT_MINUTES, + OnCallScheduleId, + UserId, } from "@maple/domain/http" import { V2EscalationChain, V2EscalationChainCreateParams, type V2EscalationStep, + V2EscalationTarget, } from "@maple/domain/http/v2" import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" import { Command, type Update } from "foldkit" @@ -29,7 +32,8 @@ import { saveEscalationChain } from "../shared/escalation-chains" /** One step as the form holds it; `key` keeps a step's row (and its menu) across reorders. */ export const StepDraft = Schema.Struct({ key: Schema.String, - destinationIds: Schema.Array(AlertDestinationId), + /** Destinations, people and schedules, in the order they were added; all kept on load. */ + targets: Schema.Array(V2EscalationTarget), waitMinutes: Schema.String, }) export type StepDraft = typeof StepDraft.Type @@ -42,7 +46,7 @@ export const Model = Schema.Struct({ steps: Schema.Array(StepDraft), repeatCount: Schema.String, nextKey: Schema.Number, - /** Each step's "Add destination" menu, by step key. */ + /** Each step's "Add target" menu, by step key. */ menus: Schema.Record(Schema.String, Menu.Model), isSaving: Schema.Boolean, /** A save was tried: every field shows what is wrong with it. */ @@ -57,7 +61,7 @@ export const DEFAULT_WAIT_MINUTES = "10" const emptyStep = (key: number): StepDraft => ({ key: `s${key}`, - destinationIds: [], + targets: [], waitMinutes: DEFAULT_WAIT_MINUTES, }) @@ -88,7 +92,7 @@ export const Message = defineMessageUnion({ ClickedRemoveStep: { key: Schema.String }, ClickedMoveStep: { key: Schema.String, direction: Direction }, GotStepMenuMessage: { key: Schema.String, message: Menu.Message }, - ClickedRemoveDestination: { key: Schema.String, id: AlertDestinationId }, + ClickedRemoveTarget: { key: Schema.String, target: Schema.String }, ClickedSave: {}, CompletedSave: { openCount: Schema.Number, result: Schema.Result(V2EscalationChain, LoadFailure) }, }) @@ -132,13 +136,51 @@ export const waitError = (raw: string): Option.Option => export const repeatError = (raw: string): Option.Option => isRepeat(numberOf(raw)) ? Option.none() : Option.some(`Repeat 0 to ${MAX_ESCALATION_REPEATS} times`) -export const destinationsError = (ids: ReadonlyArray): Option.Option => - ids.length === 0 - ? Option.some("Pick at least one destination") - : ids.length > MAX_ESCALATION_STEP_TARGETS - ? Option.some(`A step notifies at most ${MAX_ESCALATION_STEP_TARGETS} destinations`) +export const targetsError = (targets: ReadonlyArray): Option.Option => + targets.length === 0 + ? Option.some("Pick at least one destination, person or schedule") + : targets.length > MAX_ESCALATION_STEP_TARGETS + ? Option.some(`A step notifies at most ${MAX_ESCALATION_STEP_TARGETS} targets`) : Option.none() +/** A target as a menu value and a row key: `destination:`, `user:`, `schedule:`. */ +export const targetKey = (target: V2EscalationTarget): string => { + switch (target.type) { + case "destination": + return `destination:${target.destination_id}` + case "user": + return `user:${target.user_id}` + case "schedule": + return `schedule:${target.schedule_id}` + } +} + +const decodeDestinationId = Schema.decodeUnknownOption(AlertDestinationId) +const decodeUserId = Schema.decodeUnknownOption(UserId) +const decodeScheduleId = Schema.decodeUnknownOption(OnCallScheduleId) + +/** The target a menu value names, if it is well formed. */ +export const targetOfKey = (key: string): Option.Option => { + const [kind, ...rest] = Str.split(":")(key) + const id = Arr.join(rest, ":") + switch (kind) { + case "destination": + return Option.map(decodeDestinationId(id), (destination_id): V2EscalationTarget => ({ + type: "destination", + destination_id, + })) + case "user": + return Option.map(decodeUserId(id), (user_id): V2EscalationTarget => ({ type: "user", user_id })) + case "schedule": + return Option.map(decodeScheduleId(id), (schedule_id): V2EscalationTarget => ({ + type: "schedule", + schedule_id, + })) + default: + return Option.none() + } +} + export const stepsError = (steps: ReadonlyArray): Option.Option => steps.length === 0 ? Option.some("Add at least one step") @@ -147,13 +189,10 @@ export const stepsError = (steps: ReadonlyArray): Option.Option => { - const targets = Arr.map(step.destinationIds, (id) => ({ - type: "destination" as const, - destination_id: id, - })) + const targets = step.targets const minutes = numberOf(step.waitMinutes) return Arr.isReadonlyArrayNonEmpty(targets) && - Option.isNone(destinationsError(step.destinationIds)) && + Option.isNone(targetsError(targets)) && minutes !== null && isWait(minutes) ? Option.some({ targets, wait_minutes: minutes }) @@ -243,9 +282,7 @@ export const openEdit = (model: Model, chain: V2EscalationChain): Return => description: chain.description ?? "", steps: Arr.map(chain.steps, (step, index) => ({ key: `s${index}`, - destinationIds: Arr.flatMap(step.targets, (target) => - target.type === "destination" ? [target.destination_id] : [], - ), + targets: step.targets, waitMinutes: String(step.wait_minutes), })), repeatCount: String(chain.repeat_count), @@ -266,8 +303,6 @@ const moveStep = (steps: ReadonlyArray, key: string, direction: "up" return Arr.map(steps, (step, index) => (index === from.value ? other : index === to ? moving : step)) } -const decodeDestinationId = Schema.decodeUnknownOption(AlertDestinationId) - const stepMenu = (model: Model, key: string, message: Menu.Message): Return => { const id = menuIdOf(model, key) const result = Menu.update(model.menus[key] ?? Menu.init(id), message) @@ -277,15 +312,15 @@ const stepMenu = (model: Model, key: string, message: Menu.Message): Return => { ) const picked = Option.flatMap( result.outMessage?._tag === "SelectedItem" ? Option.some(result.outMessage.value) : Option.none(), - decodeDestinationId, + targetOfKey, ) return Option.match(picked, { onNone: () => ({ model: next, commands }), - onSome: (destinationId) => ({ + onSome: (target) => ({ ...editStep(next, key, (step) => - step.destinationIds.includes(destinationId) + Arr.some(step.targets, (existing) => targetKey(existing) === targetKey(target)) ? step - : { ...step, destinationIds: [...step.destinationIds, destinationId] }, + : { ...step, targets: [...step.targets, target] }, ), commands, }), @@ -335,10 +370,10 @@ export const update = (model: Model, message: Message): Return => model: { ...model, steps: moveStep(model.steps, key, direction) }, }), GotStepMenuMessage: ({ key, message: child }) => stepMenu(model, key, child), - ClickedRemoveDestination: ({ key, id }) => + ClickedRemoveTarget: ({ key, target }) => editStep(model, key, (step) => ({ ...step, - destinationIds: Arr.filter(step.destinationIds, (existing) => existing !== id), + targets: Arr.filter(step.targets, (existing) => targetKey(existing) !== target), })), ClickedSave: () => save(model), CompletedSave: ({ openCount, result }) => diff --git a/apps/web/src/page/alerts/escalation-dialog/view.ts b/apps/web/src/page/alerts/escalation-dialog/view.ts index e88a39ae78..5c505c3f93 100644 --- a/apps/web/src/page/alerts/escalation-dialog/view.ts +++ b/apps/web/src/page/alerts/escalation-dialog/view.ts @@ -1,6 +1,7 @@ import { MAX_ESCALATION_STEPS } from "@maple/domain/http" +import type { V2EscalationTarget, V2OnCallSchedule } from "@maple/domain/http/v2" import type { AlertDestinationRow } from "@maple/web-data/collections/alerts" -import { Option, Array as Arr } from "effect" +import { Option, Array as Arr, String as Str } from "effect" import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" @@ -18,15 +19,19 @@ import { icon } from "@maple/views/ui/icon" import { iconButton } from "@maple/views/ui/icon-button" import type { FieldState } from "@maple/views/ui/input" import * as Menu from "@maple/views/ui/menu" +import type { MenuEntry } from "@maple/views/ui/menu-view" import { panel } from "@maple/views/ui/panel" +import { personAvatar } from "@maple/views/ui/person-avatar" import { hstack, vstack } from "@maple/views/ui/stack" import { textarea } from "@maple/views/ui/textarea" import { typography } from "@maple/views/ui/typography" -import { destinationPicker } from "../../kit/destination-picker" +import { type PickedEntry, pickList } from "../../kit/pick-list" import { formatWait } from "../shared/escalation-chains" +import { memberName, memberOf, type OrgMember } from "../shared/org-members" import { descriptionError, - destinationsError, + targetKey, + targetsError, menuIdOf, Message, type Model, @@ -44,8 +49,118 @@ export type ViewInputs = Readonly<{ id: string /** The synced destinations steps pick from; `null` until they arrive. */ destinations: ReadonlyArray | null + /** The org's members a step can page; `null` until they load. */ + members: ReadonlyArray | null + /** The on-call schedules a step can page; empty until they load. */ + schedules: ReadonlyArray }> +const nameOr = ( + rows: ReadonlyArray | null, + id: string, + fallback: string, +): string => + Option.getOrElse( + Option.map( + Arr.findFirst(rows ?? [], (row) => row.id === id), + (row) => row.name, + ), + () => (rows === null ? fallback : `Unavailable ${Str.toLowerCase(fallback)}`), + ) + +/** A picked target's row: a destination's bell, a member's avatar, a schedule's calendar. */ +const targetEntry = ( + h: H, + inputs: ViewInputs, + key: string, + target: V2EscalationTarget, +): PickedEntry => { + const onRemove = Message.ClickedRemoveTarget({ key, target: targetKey(target) }) + switch (target.type) { + case "destination": { + const name = nameOr(inputs.destinations, target.destination_id, "Destination") + return { + key: targetKey(target), + label: name, + media: icon(h, "BellIcon", { className: "text-muted-foreground" }), + removeLabel: `Stop notifying ${name}`, + onRemove, + } + } + case "user": { + const name = memberName(inputs.members, target.user_id) + return { + key: targetKey(target), + label: name, + media: personAvatar(h, { + name, + size: "xs", + imageUrl: Option.getOrUndefined( + Option.map(memberOf(inputs.members, target.user_id), (member) => member.imageUrl), + ), + }), + detail: "email and chat DM", + removeLabel: `Stop paging ${name}`, + onRemove, + } + } + case "schedule": { + const name = nameOr(inputs.schedules, target.schedule_id, "Schedule") + return { + key: targetKey(target), + label: name, + media: icon(h, "CalendarIcon", { className: "text-muted-foreground" }), + detail: "whoever is on call", + removeLabel: `Stop paging ${name}`, + onRemove, + } + } + } +} + +/** The "Add target" menu: destinations, people and schedules not yet on the step, by kind. */ +const addEntries = (inputs: ViewInputs, step: StepDraft): ReadonlyArray => { + const picked = new Set(Arr.map(step.targets, targetKey)) + const group = ( + label: string, + items: ReadonlyArray>, + ): ReadonlyArray => { + const open = Arr.filter(items, (item) => !picked.has(item.value)) + return open.length === 0 + ? [] + : [ + { + _tag: "Group", + label, + entries: Arr.map(open, (item): MenuEntry => ({ _tag: "Item", ...item })), + }, + ] + } + return [ + ...group( + "Destinations", + Arr.map(inputs.destinations ?? [], (row) => ({ + value: `destination:${row.id}`, + label: row.name, + })), + ), + ...group( + "People", + Arr.map(inputs.members ?? [], (member) => ({ + value: `user:${member.userId}`, + label: member.name, + })), + ), + ...group( + "Whoever is on call on", + Arr.map(inputs.schedules, (schedule) => ({ + value: `schedule:${schedule.id}`, + label: schedule.name, + })), + ), + ] +} + const stateOf = (value: string, isInvalid: boolean): FieldState => ({ isInvalid, isDirty: false, @@ -157,7 +272,7 @@ const stepBlock = (h: H, model: Model, inputs: ViewInputs, step: StepDraft, inde const count = model.steps.length const previous = index === 0 ? undefined : model.steps[index - 1] const wait = waitError(step.waitMinutes) - const destinations = shownError(model, destinationsError(step.destinationIds)) + const targets = shownError(model, targetsError(step.targets)) const key = step.key return panel(h, { padded: "sm", className: "gap-3", attributes: [h.Key(key)] }, [ hstack(h, { gap: "2", justify: "between" }, [ @@ -203,18 +318,15 @@ const stepBlock = (h: H, model: Model, inputs: ViewInputs, step: StepDraft, inde ), ]), ]), - destinationPicker(h, { - rows: inputs.destinations, - selectedIds: step.destinationIds, + pickList(h, { + entries: Arr.map(step.targets, (target) => targetEntry(h, inputs, key, target)), menu: model.menus[key] ?? Menu.init(menuIdOf(model, key)), toMenuMessage: (message) => Message.GotStepMenuMessage({ key, message }), - onRemove: (id) => Message.ClickedRemoveDestination({ key, id }), - emptyText: - inputs.destinations !== null && inputs.destinations.length === 0 - ? "No destinations exist yet. Add one in Routing first." - : "Pick who this step pages", + addEntries: addEntries(inputs, step), + addLabel: "Add destination, person or schedule", + emptyText: "Pick who this step pages", }), - ...Option.match(destinations, { + ...Option.match(targets, { onNone: () => [], onSome: (text) => [fieldErrorText(h, { attributes: [h.Role("alert")] }, [text])], }), @@ -276,7 +388,7 @@ const descriptionField = (h: H, model: Model): Html => { ]) } -/** The escalation chain dialog: name, ordered steps of destinations and waits, and repeats. */ +/** The escalation chain dialog: name, ordered steps of targets and waits, and repeats. */ export const view = Submodel.defineView((model, inputs, h) => { const repeat = repeatError(model.repeatCount) const isEditing = model.editingId !== null @@ -287,7 +399,7 @@ export const view = Submodel.defineView((model, inpu className: "sm:max-w-xl", title: isEditing ? "Edit escalation chain" : "New escalation chain", description: - "Each step pages its destinations, then waits for someone to acknowledge before paging the next. Acknowledging pauses the chain and resolving ends it.", + "Each step pages its destinations, people and whoever is on call, then waits for someone to acknowledge before paging the next. Acknowledging pauses the chain and resolving ends it.", panelClassName: "space-y-5", fields: [ textField(h, { diff --git a/apps/web/src/page/alerts/hub/command.ts b/apps/web/src/page/alerts/hub/command.ts index 1f470ee9f6..280c250006 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, EscalationChainId } from "@maple/domain/http" +import { AlertDestinationId, AlertRuleId, EscalationChainId, OnCallScheduleId } 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,8 @@ import { updateDestination, } from "../shared/commands" import { deleteEscalationChain, listEscalationChains } from "../shared/escalation-chains" +import { deleteOnCallSchedule, listOnCallSchedules } from "../shared/oncall-schedules" +import { listOrgMembers } from "../shared/org-members" import { listSloRows } from "../shared/slo-commands" import { deleteMonitor, listMonitors, setMonitorEnabled } from "../shared/uptime-commands" import { Message } from "./message" @@ -186,3 +188,34 @@ export const DeleteChain = Command.define("DeleteEscalationChain", { Effect.map((result) => Message.CompletedDeleteChain({ chainId, result })), ), }) + +/** The on-call schedules, each with who is on call as the API reads it now. */ +export const FetchHubSchedules = Command.define("FetchAlertsHubSchedules", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchSchedules], + execute: ({ key }) => + Effect.gen(function* () { + const result = yield* Effect.result(listOnCallSchedules) + const readAtMs = yield* Clock.currentTimeMillis + return Message.CompletedFetchSchedules({ key, readAtMs, result }) + }), +}) + +/** The org's members, to name who is on call and to pick from in the chain dialog. */ +export const FetchHubMembers = Command.define("FetchAlertsHubMembers", { + messages: [Message.CompletedFetchMembers], + execute: listOrgMembers.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchMembers({ result })), + ), +}) + +export const DeleteSchedule = Command.define("DeleteOnCallSchedule", { + args: { scheduleId: OnCallScheduleId }, + messages: [Message.CompletedDeleteSchedule], + execute: ({ scheduleId }) => + deleteOnCallSchedule(scheduleId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedDeleteSchedule({ scheduleId, result })), + ), +}) diff --git a/apps/web/src/page/alerts/hub/derive.ts b/apps/web/src/page/alerts/hub/derive.ts index 200125c77b..3e39086c2c 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" | "slos" | "routing" +export type AlertsTab = "inbox" | "rules" | "uptime" | "slos" | "oncall" | "routing" /** Legacy `tab` values map onto the current tabs so old links keep resolving. */ export const resolveTab = (tab: string | undefined): AlertsTab => { @@ -64,6 +64,8 @@ export const resolveTab = (tab: string | undefined): AlertsTab => { return "uptime" case "slos": return "slos" + case "oncall": + return "oncall" case "routing": case "settings": return "routing" diff --git a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts index 08609164f5..5fdcce2ad1 100644 --- a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts @@ -119,7 +119,7 @@ describe("alerts hub, escalation chains", () => { submit(selector("form")), Command.expectNone(), expectView(text("Name the chain")).toExist(), - expectView(text("Pick at least one destination")).toExist(), + expectView(text("Pick at least one destination, person or schedule")).toExist(), ) }) diff --git a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts new file mode 100644 index 0000000000..255a459c9f --- /dev/null +++ b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts @@ -0,0 +1,173 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { OnCallScheduleId } from "@maple/domain/http" +import type { V2OnCallSchedule } from "@maple/domain/http/v2" +import { Result, Schema, Array as Arr } from "effect" +import * as Menu from "@maple/views/ui/menu" +import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import type { PageContext } from "../../contract" +import { chainDestinations } from "../shared/escalation-test-fixtures" +import { SCHEDULE_IN_USE_CODE } from "../shared/oncall-schedules" +import { + mixedTargetChain, + ONCALL_NOW, + oncallMembers, + populatedSchedules, + scheduleUuid, +} from "../shared/oncall-test-fixtures" +import { incidentRow, ORG, ruleRow } from "../shared/test-rows" +import { Message } from "./message" +import type { Model } from "./model" +import { init, scheduleMenuId, 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 names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) + +const ENGINEER = Policy.grantsOf(["org:engineer"]) +const MEMBER = Policy.grantsOf(["org:member"]) +const SCHEDULES = populatedSchedules(ONCALL_NOW) + +const onTab = ( + tab: string, + grants: ReturnType, + schedules: ReadonlyArray, +) => { + const entered = init({ _tag: "Alerts", search: { tab } }, context) + expect(names(entered.commands)).toContain("FetchAlertsHubSchedules") + return Arr.reduce( + [ + Message.ReadClock({ nowMs: ONCALL_NOW }), + Message.CompletedFetchSession({ orgId: ORG, grants }), + Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + Message.UpdatedDestinations({ orgId: ORG, rows: chainDestinations }), + Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(1), ruleRow(2)] }), + Message.UpdatedRuleStates({ orgId: ORG, rows: [] }), + Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow()] }), + Message.CompletedFetchChains({ + key: entered.model.chains.key ?? "", + result: Result.succeed([mixedTargetChain(chainDestinations[0]?.id ?? "")]), + }), + Message.CompletedFetchSchedules({ + key: entered.model.schedules.key ?? "", + readAtMs: ONCALL_NOW, + result: Result.succeed(schedules), + }), + ], + entered.model, + (model, message) => pageUpdate(model, message).model, + ) +} + +describe("alerts hub, on-call schedules", () => { + test("an org without schedules learns what one is and can create the first", () => { + scene( + { update: pageUpdate, view }, + given(onTab("oncall", ENGINEER, [])), + expectView(text("No on-call schedules")).toExist(), + expectView(text(/people take turns in layers/u)).toExist(), + expectView(role("link", { name: "Create schedule" })).toExist(), + ) + }) + + test("each schedule names its zone, who is on call until when, and the next handoff", () => { + scene( + { update: pageUpdate, view }, + given(onTab("oncall", ENGINEER, SCHEDULES)), + expectView(text("Checkout primary")).toExist(), + expectView(text("Europe/Berlin · 3 layers")).toExist(), + expectView(text("Sofia Alvarez")).toExist(), + expectView(text(/^until Tue,? 10 Nov,? 17:00$/u)).toExist(), + expectView(text("Hands off in 5h 30m")).toExist(), + expectView(text("Nobody on call")).toExist(), + expectView(role("link", { name: "New schedule" })).toExist(), + expectView(role("button", { name: "Actions for Checkout primary" })).toExist(), + ) + }) + + test("a member sees the schedules but cannot create, edit or delete one", () => { + scene( + { update: pageUpdate, view }, + given(onTab("oncall", MEMBER, SCHEDULES)), + expectView(text("Checkout primary")).toExist(), + expectView(role("link", { name: "New schedule" })).not.toExist(), + expectView(role("button", { name: "Actions for Checkout primary" })).not.toExist(), + ) + }) + + test("the inbox opens with who is on call on each schedule", () => { + scene( + { update: pageUpdate, view }, + given(onTab("inbox", MEMBER, SCHEDULES)), + expectView(role("region", { name: "On call now" })).toExist(), + expectView(text("Sofia Alvarez")).toExist(), + expectView(text("Priya Raman")).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(onTab("inbox", MEMBER, [])), + expectView(role("region", { name: "On call now" })).not.toExist(), + ) + }) + + test("the clock reads the schedules again once a shown shift has handed off, not before", () => { + const model = onTab("oncall", ENGINEER, SCHEDULES) + expect( + names(pageUpdate(model, Message.ReadClock({ nowMs: ONCALL_NOW + 60_000 })).commands), + ).not.toContain("FetchAlertsHubSchedules") + expect( + names(pageUpdate(model, Message.ReadClock({ nowMs: ONCALL_NOW + 4 * 3_600_000 })).commands), + ).toContain("FetchAlertsHubSchedules") + }) + + test("a chain's summary names the people and schedules it pages", () => { + scene( + { update: pageUpdate, view }, + given(onTab("routing", ENGINEER, SCHEDULES)), + expectView( + text( + "Slack #alerts-api, then whoever is on call on Checkout primary and Dana Kim after 5m, then Ben Brook after 15m; repeats once", + ), + ).toExist(), + ) + }) + + test("a refused delete names the chains that still page the schedule", () => { + const model = onTab("oncall", ENGINEER, SCHEDULES) + const scheduleId = Schema.decodeSync(OnCallScheduleId)(scheduleUuid(1)) + const asked = pageUpdate( + pageUpdate( + model, + Message.GotRowMenuMessage({ + menuId: scheduleMenuId(scheduleId), + message: Menu.Message.ClickedItem({ index: 1, item: { kind: "Item", value: "delete" } }), + }), + ).model, + Message.ClickedConfirmDeleteSchedule(), + ) + expect(names(asked.commands)).toEqual(["DeleteOnCallSchedule"]) + const refused = pageUpdate( + asked.model, + Message.CompletedDeleteSchedule({ + scheduleId, + result: Result.fail({ + _tag: "@maple/http/errors/OnCallScheduleInUseError", + type: "conflict_error", + code: SCHEDULE_IN_USE_CODE, + title: "Schedule is in use", + message: "An escalation chain pages this schedule.", + retryable: false, + recovery: "fix_request", + }), + }), + ) + expect(refused.model.deleteScheduleError).toBe( + "Still paged by Checkout on-call. Remove it from those chains first.", + ) + }) +}) diff --git a/apps/web/src/page/alerts/hub/message.ts b/apps/web/src/page/alerts/hub/message.ts index b11118d95c..beb94f677e 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -5,8 +5,9 @@ import { AlertDestinationId, AlertRuleId, EscalationChainId, + OnCallScheduleId, } from "@maple/domain/http" -import { V2EscalationChain, V2UptimeMonitor } from "@maple/domain/http/v2" +import { V2EscalationChain, V2OnCallSchedule, V2UptimeMonitor } from "@maple/domain/http/v2" import { AlertDestinationRowSchema, AlertIncidentRowSchema, @@ -20,6 +21,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 ChainDialog from "../escalation-dialog/dialog" +import { OrgMember } from "../shared/org-members" import { SloListRow, TestOutcome, UptimeKindFilter, UptimeStatusFilter } from "./model" import { ServiceNamespaces } from "./uptime-groups" @@ -84,5 +86,15 @@ export const Message = defineMessageUnion({ ClickedConfirmDeleteChain: {}, CompletedDeleteChain: { chainId: EscalationChainId, result: outcome(Schema.Void) }, GotDeleteChainDialogMessage: { message: Dialog.Message }, + CompletedFetchSchedules: { + key: Schema.String, + readAtMs: Schema.Number, + result: outcome(Schema.Array(V2OnCallSchedule)), + }, + ClickedRetrySchedules: {}, + CompletedFetchMembers: { result: Schema.Result(Schema.Array(OrgMember), Schema.String) }, + ClickedConfirmDeleteSchedule: {}, + CompletedDeleteSchedule: { scheduleId: OnCallScheduleId, result: outcome(Schema.Void) }, + GotDeleteScheduleDialogMessage: { message: Dialog.Message }, }) 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 71bade60a7..0670642e99 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -5,8 +5,15 @@ import { AlertDestinationId, AlertRuleId, EscalationChainId, + OnCallScheduleId, } from "@maple/domain/http" -import { V2EscalationChain, V2Slo, V2SloStatus, V2UptimeMonitor } from "@maple/domain/http/v2" +import { + V2EscalationChain, + V2OnCallSchedule, + V2Slo, + V2SloStatus, + V2UptimeMonitor, +} from "@maple/domain/http/v2" import { AlertDestinationRowSchema, AlertIncidentRowSchema, @@ -21,6 +28,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 ChainDialog from "../escalation-dialog/dialog" +import { OrgMember } from "../shared/org-members" import { ServiceNamespaces } from "./uptime-groups" // The `/alerts` hub. @@ -94,6 +102,14 @@ export const Model = Schema.Struct({ deletingChainId: Schema.NullOr(EscalationChainId), deleteChainError: Schema.NullOr(Schema.String), deleteChainDialog: Dialog.Model, + /** `oncallSchedules.list`, read on every tab: the inbox strip, the On-call tab and the chain dialog. */ + schedules: KeyedSchema(Schema.Array(V2OnCallSchedule), LoadFailure), + /** The org's members, to name who is on call; `null` until Clerk answers (or failed). */ + members: Schema.NullOr(Schema.Array(OrgMember)), + confirmDeleteScheduleId: Schema.NullOr(OnCallScheduleId), + deletingScheduleId: Schema.NullOr(OnCallScheduleId), + deleteScheduleError: Schema.NullOr(Schema.String), + deleteScheduleDialog: Dialog.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 62e61d242e..f1fc8824a4 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -1,6 +1,6 @@ import type { UptimeMonitorId } from "@maple/domain" -import type { AlertDestinationId, AlertRuleId, EscalationChainId } from "@maple/domain/http" -import type { V2EscalationChain } from "@maple/domain/http/v2" +import type { AlertDestinationId, AlertRuleId, EscalationChainId, OnCallScheduleId } from "@maple/domain/http" +import type { V2EscalationChain, V2OnCallSchedule } from "@maple/domain/http/v2" 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" @@ -19,9 +19,18 @@ import * as ChainDialog from "../escalation-dialog/dialog" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" import { alertsHref, createHref, ruleHref } from "../shared/links" import { CHAIN_IN_USE_CODE, CHAIN_LIST_LIMIT } from "../shared/escalation-chains" +import { + editScheduleHref, + handoffMs, + SCHEDULE_IN_USE_CODE, + SCHEDULE_LIST_LIMIT, +} from "../shared/oncall-schedules" import { newSloHref, SLO_LIST_LIMIT } from "../shared/slo-commands" import { editMonitorHref, newMonitorHref, UPTIME_LIST_LIMIT } from "../shared/uptime-commands" import { + DeleteSchedule, + FetchHubMembers, + FetchHubSchedules, DeleteChain, DeleteDestination, FetchHubChains, @@ -50,6 +59,7 @@ type HubRoute = Extract const DELETE_DIALOG_ID = "alerts-delete-destination" const DELETE_MONITOR_DIALOG_ID = "alerts-delete-monitor" const DELETE_CHAIN_DIALOG_ID = "alerts-delete-chain" +export const DELETE_SCHEDULE_DIALOG_ID = "alerts-delete-schedule" export const CHAIN_DIALOG_ID = "alerts-escalation-chain" export const NEW_RULE_MENU_ID = "alerts-new-rule-menu" @@ -138,9 +148,10 @@ const requestSlos = (model: Model, context: PageContext, mode: Keyed.ReadMode): const chainsKey = (context: PageContext) => requestKey(context.orgId, "escalationChains.list", { query: { limit: CHAIN_LIST_LIMIT } }) -/** The Routing tab reads the escalation chains only while it is open. */ +/** The Routing tab reads the escalation chains, and the On-call tab to name the ones paging a schedule. */ const requestChains = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => { - if (resolveTab(model.search.tab) !== "routing") return { model, commands: [] } + const tab = resolveTab(model.search.tab) + if (tab !== "routing" && tab !== "oncall") return { model, commands: [] } return Option.match(Keyed.requestFor(model.chains, chainsKey(context), mode), { onNone: () => ({ model, commands: [] }), onSome: (chains) => ({ @@ -150,6 +161,42 @@ const requestChains = (model: Model, context: PageContext, mode: Keyed.ReadMode) }) } +const schedulesKey = (context: PageContext) => + requestKey(context.orgId, "oncallSchedules.list", { query: { limit: SCHEDULE_LIST_LIMIT } }) + +/** Every tab reads the schedules: the inbox strip, the On-call tab and the chain dialog's targets. */ +const requestSchedules = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => + Option.match(Keyed.requestFor(model.schedules, schedulesKey(context), mode), { + onNone: () => ({ model, commands: [] }), + onSome: (schedules) => ({ + model: { ...model, schedules }, + commands: [FetchHubSchedules({ key: schedules.key })], + }), + }) + +/** Re-read at most every 5 minutes, and at once when a shift the list shows has handed off. */ +const SCHEDULES_MAX_AGE_MS = 5 * 60_000 + +const schedulesOf = (model: Model): ReadonlyArray => + Option.getOrElse(AsyncData.getData(model.schedules.data), () => []) + +const isSchedulesStale = (model: Model, nowMs: number): boolean => { + const readAt = model.schedules.updatedAtMs + if (readAt === undefined || !AsyncData.isSuccess(model.schedules.data)) return false + return ( + nowMs - readAt >= SCHEDULES_MAX_AGE_MS || + Array.some(schedulesOf(model), (schedule) => Option.exists(handoffMs(schedule), (at) => at <= nowMs)) + ) +} + +/** The clock ticked: the overview's derivation moves, and stale schedules are read again. */ +const clockRead = (model: Model, context: PageContext, nowMs: number): Return => { + const next = { ...model, nowMs } + if (!isSchedulesStale(next, nowMs)) return { model: next } + const step = requestSchedules(next, context, "Refresh") + return { model: step.model, commands: step.commands } +} + /** `reactivityKeys: ["alertDeliveryEvents"]`: both reads revalidate. */ const refreshDeliveries = (model: Model, context: PageContext) => { const overview = requestDeliveries(model, context, "Refresh") @@ -197,17 +244,26 @@ export const init = (route: HubRoute, context: PageContext): Return => { deletingChainId: null, deleteChainError: null, deleteChainDialog: Dialog.init(DELETE_CHAIN_DIALOG_ID, "alert-dialog"), + schedules: Keyed.idle(), + members: null, + confirmDeleteScheduleId: null, + deletingScheduleId: null, + deleteScheduleError: null, + deleteScheduleDialog: Dialog.init(DELETE_SCHEDULE_DIALOG_ID, "alert-dialog"), } const deliveries = requestDeliveries(model, context, "Load") const routing = requestRoutingDeliveries(deliveries.model, context, "Load") const monitors = requestMonitors(routing.model, context, "Load") const slos = requestSlos(monitors.model, context, "Load") const chains = requestChains(slos.model, context, "Load") + const schedules = requestSchedules(chains.model, context, "Load") return { - model: chains.model, + model: schedules.model, commands: [ ReadHubClock(), FetchHubSession({ orgId: context.orgId }), + FetchHubMembers(), + ...schedules.commands, ...deliveries.commands, ...routing.commands, ...monitors.commands, @@ -222,9 +278,16 @@ export const routeChanged = (model: Model, route: HubRoute, context: PageContext const monitors = requestMonitors(routing.model, context, "Load") const slos = requestSlos(monitors.model, context, "Load") const chains = requestChains(slos.model, context, "Load") + const schedules = requestSchedules(chains.model, context, "Load") return { - model: chains.model, - commands: [...routing.commands, ...monitors.commands, ...slos.commands, ...chains.commands], + model: schedules.model, + commands: [ + ...routing.commands, + ...monitors.commands, + ...slos.commands, + ...chains.commands, + ...schedules.commands, + ], } } @@ -432,6 +495,111 @@ const deletedChain = ( }) } +// ON-CALL SCHEDULES + +/** A write changed the schedules: read them again under a new generation. */ +const refreshSchedules = (model: Model, context: PageContext): Return => { + const step = requestSchedules(model, context, "Supersede") + return { model: step.model, commands: step.commands } +} + +/** The chains that page a schedule, by name, for the in-use refusal. */ +const scheduleChainNames = (model: Model, schedule: V2OnCallSchedule): ReadonlyArray => + Array.flatMap(schedule.chain_ids, (id) => + Option.toArray( + Option.map( + Array.findFirst(chainsOf(model), (chain) => chain.id === id), + (chain) => chain.name, + ), + ), + ) + +const scheduleInUseMessage = (model: Model, scheduleId: OnCallScheduleId): string => { + const names = Option.match( + Array.findFirst(schedulesOf(model), (schedule) => schedule.id === scheduleId), + { onNone: () => [], onSome: (schedule) => scheduleChainNames(model, schedule) }, + ) + return names.length === 0 + ? "An escalation chain still pages this schedule. Remove it from the chain first." + : `Still paged by ${Array.join(names, ", ")}. Remove it from those chains first.` +} + +/** Runs the schedule delete dialog's update; `Closed` forgets the target and its error. */ +const updateDeleteScheduleDialog = (model: Model, step: ReturnType): Return => { + const isClosed = step.outMessage?._tag === "Closed" + return { + model: { + ...model, + deleteScheduleDialog: step.model, + confirmDeleteScheduleId: isClosed ? null : model.confirmDeleteScheduleId, + deleteScheduleError: isClosed ? null : model.deleteScheduleError, + }, + commands: FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotDeleteScheduleDialogMessage({ message }), + ), + } +} + +const pickedScheduleAction = (model: Model, rawId: string, value: string): Return => + Option.match( + Array.findFirst(schedulesOf(model), (schedule) => schedule.id === rawId), + { + onNone: () => ({ model }), + onSome: (schedule): Return => + value === "delete" + ? updateDeleteScheduleDialog( + { ...model, confirmDeleteScheduleId: schedule.id, deleteScheduleError: null }, + Dialog.open(model.deleteScheduleDialog), + ) + : { + model, + outMessage: PageOutMessage.RequestedNavigation({ + url: editScheduleHref(schedule.id), + isReplace: false, + }), + }, + }, + ) + +const confirmDeleteSchedule = (model: Model): Return => { + const id = model.confirmDeleteScheduleId + return id === null || model.deletingScheduleId !== null + ? { model } + : { + model: { ...model, deletingScheduleId: id, deleteScheduleError: null }, + commands: [DeleteSchedule({ scheduleId: id })], + } +} + +const deletedSchedule = ( + model: Model, + context: PageContext, + scheduleId: OnCallScheduleId, + result: Result.Result, +): Return => { + const settled = { ...model, deletingScheduleId: null } + return Result.match(result, { + onSuccess: (): Return => { + const closed = updateDeleteScheduleDialog(settled, Dialog.close(settled.deleteScheduleDialog)) + const refreshed = refreshSchedules(closed.model, context) + return { + model: refreshed.model, + commands: [...(closed.commands ?? []), ...(refreshed.commands ?? [])], + outMessage: toast("success", "Schedule deleted"), + } + }, + onFailure: (failure): Return => ({ + model: { + ...settled, + deleteScheduleError: + failure.code === SCHEDULE_IN_USE_CODE + ? scheduleInUseMessage(settled, scheduleId) + : failure.message, + }, + }), + }) +} + // ROW MENUS /** @@ -442,6 +610,7 @@ export const ruleMenuId = (ruleId: AlertRuleId, groupKey: string) => `rule:${rul export const destinationMenuId = (destinationId: AlertDestinationId) => `destination:${destinationId}` export const monitorMenuId = (monitorId: UptimeMonitorId) => `monitor:${monitorId}` export const chainMenuId = (chainId: EscalationChainId) => `chain:${chainId}` +export const scheduleMenuId = (scheduleId: OnCallScheduleId) => `schedule:${scheduleId}` const monitorsOf = (model: Model) => Option.getOrElse(AsyncData.getData(model.monitors.data), () => []) @@ -532,6 +701,7 @@ const pickedFromMenu = (model: Model, context: PageContext, menuId: string, valu const target = Array.join(rest, ":") if (kind === "monitor") return pickedMonitorAction(model, target, value) if (kind === "chain") return pickedChainAction(model, context, target, value) + if (kind === "schedule") return pickedScheduleAction(model, target, value) if (kind === "rule") { const id = Str.split("@")(target)[0] const rule = Option.map( @@ -598,7 +768,7 @@ const statusToggled = (model: Model, bucket: RuleBucket): Return => ({ export const update = (model: Model, message: Message, context: PageContext): Return => Message.match(message, { - ReadClock: ({ nowMs }) => ({ model: { ...model, nowMs } }), + ReadClock: ({ nowMs }) => clockRead(model, context, nowMs), UpdatedRules: ({ orgId, rows }) => orgId === context.orgId ? { model: { ...model, rules: rows } } : { model }, UpdatedRuleStates: ({ orgId, rows }) => @@ -722,6 +892,23 @@ export const update = (model: Model, message: Message, context: PageContext): Re model.deletingChainId !== null ? { model } : updateDeleteChainDialog(model, Dialog.update(model.deleteChainDialog, child)), + CompletedFetchSchedules: ({ key, readAtMs, result }) => ({ + model: { ...model, schedules: Keyed.accept(model.schedules, key, result, readAtMs) }, + }), + ClickedRetrySchedules: () => refreshSchedules(model, context), + // Without members the names read "Member"; the schedules themselves still show. + CompletedFetchMembers: ({ result }) => + Result.match(result, { + onSuccess: (members) => ({ model: { ...model, members } }), + onFailure: () => ({ model }), + }), + ClickedConfirmDeleteSchedule: () => confirmDeleteSchedule(model), + CompletedDeleteSchedule: ({ scheduleId, result }) => + deletedSchedule(model, context, scheduleId, result), + GotDeleteScheduleDialogMessage: ({ message: child }) => + model.deletingScheduleId !== null + ? { model } + : updateDeleteScheduleDialog(model, Dialog.update(model.deleteScheduleDialog, child)), ClickedRetrySlos: () => { const step = requestSlos(model, context, "Supersede") return { model: step.model, commands: step.commands } diff --git a/apps/web/src/page/alerts/hub/view-chains.ts b/apps/web/src/page/alerts/hub/view-chains.ts index 4902abbcd4..944d83ca56 100644 --- a/apps/web/src/page/alerts/hub/view-chains.ts +++ b/apps/web/src/page/alerts/hub/view-chains.ts @@ -22,6 +22,7 @@ import { typography } from "@maple/views/ui/typography" import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" import { sectionHeading } from "../../kit/section-heading" import { chainSummary } from "../shared/escalation-chains" +import { memberName } from "../shared/org-members" import { Message } from "./message" import { canManage, type Model } from "./model" import { chainMenuId, chainRuleNames } from "./update" @@ -48,7 +49,20 @@ export const chainRow = ( chain: V2EscalationChain, destinations: ReadonlyArray, ): Html => { - const summary = chainSummary(chain, nameOfDestination(destinations)) + const summary = chainSummary(chain, nameOfDestination(destinations), { + userName: (id) => memberName(model.members, id), + scheduleName: (id) => + Option.getOrElse( + Option.map( + Arr.findFirst( + Option.getOrElse(AsyncData.getData(model.schedules.data), () => []), + (schedule) => schedule.id === id, + ), + (schedule) => schedule.name, + ), + () => "a deleted schedule", + ), + }) const ruleNames = chainRuleNames(model, chain) const menuId = chainMenuId(chain.id) const entries: ReadonlyArray = [ diff --git a/apps/web/src/page/alerts/hub/view-oncall.ts b/apps/web/src/page/alerts/hub/view-oncall.ts new file mode 100644 index 0000000000..022a8f8dbd --- /dev/null +++ b/apps/web/src/page/alerts/hub/view-oncall.ts @@ -0,0 +1,286 @@ +import type { V2OnCallSchedule } from "@maple/domain/http/v2" +import { pluralize } from "@maple/ui/lib/format" +import { Array as Arr, Option } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" +import { borderedBox } from "@maple/views/ui/bordered-box" +import { linkButton } from "@maple/views/ui/button" +import { confirmDialog } from "@maple/views/ui/confirm-dialog" +import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "@maple/views/ui/empty" +import { errorState } from "@maple/views/ui/error-state" +import { eyebrow } from "@maple/views/ui/eyebrow" +import { icon } from "@maple/views/ui/icon" +import * as Menu from "@maple/views/ui/menu" +import type { MenuEntry } from "@maple/views/ui/menu-view" +import { personLabel } from "@maple/views/ui/person-avatar" +import { rowActionsMenu } from "@maple/views/ui/row-actions-menu" +import { emptyActions } from "@maple/views/ui/signal-empty-state" +import { skeleton } from "@maple/views/ui/skeleton" +import { hstack, vstack } from "@maple/views/ui/stack" +import { textLink } from "@maple/views/ui/text-link" +import { typography } from "@maple/views/ui/typography" +import { sectionHeading } from "../../kit/section-heading" +import { formatShiftEdge, formatUntil, newScheduleHref, scheduleHref } from "../shared/oncall-schedules" +import { memberName, memberOf } from "../shared/org-members" +import { Message } from "./message" +import { canManage, type Model } from "./model" +import { DELETE_SCHEDULE_DIALOG_ID, scheduleMenuId } from "./update" +import { isoMs } from "../shared/time" + +// The hub's On-call tab (each schedule with who is on call and the next handoff), the inbox's +// "On call now" strip, and the schedule delete confirmation. + +type H = HtmlBuilder + +/** Who holds the schedule now, with when they hand off; "Nobody on call" in a gap. */ +const onCallPerson = (h: H, model: Model, schedule: V2OnCallSchedule): Html => + Option.match(Option.fromNullishOr(schedule.on_call_now), { + onNone: () => + hstack(h, { gap: "2" }, [ + icon(h, "UserIcon", { className: "text-foreground-subtle" }), + typography(h, { size: "sm", tone: "muted", className: "whitespace-nowrap" }, ["Nobody on call"]), + ]), + onSome: (now) => + personLabel(h, { + name: memberName(model.members, now.user_id), + imageUrl: Option.getOrUndefined( + Option.map(memberOf(model.members, now.user_id), (member) => member.imageUrl), + ), + detail: `until ${formatShiftEdge(isoMs(now.ends_at), schedule.time_zone)}`, + }), + }) + +const layerCount = (schedule: V2OnCallSchedule) => + `${schedule.layers.length} ${pluralize(schedule.layers.length, "layer")}` + +/** `ScheduleRow`: name and zone, who is on call until when, the next handoff, and its actions. */ +const scheduleRow = (h: H, model: Model, schedule: V2OnCallSchedule, nowMs: number): Html => { + const menuId = scheduleMenuId(schedule.id) + const entries: ReadonlyArray = [ + { _tag: "Item", value: "edit", label: "Edit", icon: "PencilIcon" }, + { _tag: "Separator" }, + { + _tag: "Item", + value: "delete", + label: "Delete", + icon: "TrashIcon", + variant: "destructive", + isDisabled: model.deletingScheduleId === schedule.id, + }, + ] + const handoff = Option.match(Option.fromNullishOr(schedule.on_call_now), { + onNone: () => "No one scheduled", + onSome: (now) => `Hands off ${formatUntil(nowMs, isoMs(now.ends_at))}`, + }) + return h.li( + [h.Key(schedule.id), h.Class("px-4 py-3")], + [ + hstack(h, { gap: "3", wrap: true }, [ + hstack(h, { gap: "3", align: "start", grow: true, shrink: true, className: "basis-60" }, [ + icon(h, "CalendarIcon", { className: "mt-0.5 shrink-0 text-muted-foreground" }), + vstack(h, { gap: "0.5", shrink: true }, [ + textLink( + h, + { + href: scheduleHref(schedule.id), + tone: "muted", + className: "min-w-0 text-foreground", + }, + [ + typography(h, { size: "sm", weight: "medium", truncate: true }, [ + schedule.name, + ]), + ], + ), + typography(h, { size: "xs", tone: "muted", truncate: true }, [ + `${schedule.time_zone} · ${layerCount(schedule)}`, + ]), + ]), + ]), + hstack(h, { gap: "4", className: "w-full pl-7 sm:w-auto sm:pl-0" }, [ + h.div( + [h.Class("min-w-0 flex-1 sm:w-56 sm:flex-none")], + [onCallPerson(h, model, schedule)], + ), + typography( + h, + { + size: "xs", + tone: "muted", + numeric: true, + className: "hidden shrink-0 whitespace-nowrap sm:inline sm:w-36", + }, + [handoff], + ), + ...(canManage(model) + ? [ + rowActionsMenu(h, { + slotId: menuId, + model: model.menus[menuId] ?? Menu.init(menuId), + entries, + label: `Actions for ${schedule.name}`, + toParentMessage: (message) => + Message.GotRowMenuMessage({ menuId, message }), + }), + ] + : []), + ]), + ]), + ], + ) +} + +const schedulesEmpty = (h: H, model: Model): Html => + empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "CalendarIcon", { size: 18 })]), + emptyTitle(h, ["No on-call schedules"]), + emptyDescription(h, [ + "A schedule says who is on call when: people take turns in layers that hand off daily, weekly or every few hours, and overrides cover swaps. Escalation chains page whoever is on call.", + ...(canManage(model) ? [] : [" Ask an org admin to create one."]), + ]), + ]), + ...(canManage(model) + ? [ + emptyActions(h, [ + linkButton(h, { size: "sm", href: newScheduleHref() }, [ + icon(h, "PlusIcon"), + "Create schedule", + ]), + ]), + ] + : []), + ], + "py-12", + ) + +const schedulesList = (h: H, model: Model, schedules: ReadonlyArray, nowMs: number): Html => + schedules.length === 0 + ? schedulesEmpty(h, model) + : borderedBox(h, { overflow: "hidden" }, [ + h.ul( + [h.Class("divide-y")], + Arr.map(schedules, (schedule) => scheduleRow(h, model, schedule, nowMs)), + ), + ]) + +const LOADING_ROWS = ["h-16 w-full", "h-16 w-full"] + +/** The On-call tab: what schedules are, then the list, or its empty, loading or failed state. */ +export const oncallTab = (h: H, model: Model, nowMs: number): Html => { + const loading = vstack( + h, + { gap: "3" }, + Arr.map(LOADING_ROWS, (row) => skeleton(h, row)), + ) + return h.section( + [h.Class("space-y-4")], + [ + h.div( + [], + [ + sectionHeading(h, { title: "On-call schedules" }), + typography(h, { size: "sm", tone: "muted", as: "p" }, [ + "Who is on call when. Later layers sit on top of earlier ones, and an override beats every layer.", + ]), + ], + ), + AsyncData.match(model.schedules.data, { + onIdle: () => loading, + onLoading: () => loading, + onFailure: (failure) => + errorState(h, { + failure, + title: "Couldn't load on-call schedules", + onRetry: Message.ClickedRetrySchedules(), + }), + onRefreshing: (schedules) => schedulesList(h, model, schedules, nowMs), + onStale: ({ data }) => schedulesList(h, model, data, nowMs), + onSuccess: (schedules) => schedulesList(h, model, schedules, nowMs), + }), + ], + ) +} + +/** The org has a schedule: the header offers another. */ +export const hasSchedules = (model: Model): boolean => + Option.exists(AsyncData.getData(model.schedules.data), (schedules) => schedules.length > 0) + +const stripItem = (h: H, model: Model, schedule: V2OnCallSchedule): Html => + vstack( + h, + { gap: "1", shrink: true, className: "min-w-44 flex-1 basis-56", attributes: [h.Key(schedule.id)] }, + [ + textLink(h, { href: scheduleHref(schedule.id), tone: "muted", className: "min-w-0" }, [ + typography(h, { size: "xs", truncate: true }, [schedule.name]), + ]), + onCallPerson(h, model, schedule), + ], + ) + +/** "On call now": each schedule's current person, atop the inbox; nothing without schedules. */ +export const whoIsOnCallStrip = (h: H, model: Model): ReadonlyArray => { + const schedules = Option.getOrElse(AsyncData.getData(model.schedules.data), () => []) + return schedules.length === 0 + ? [] + : [ + borderedBox( + h, + { overflow: "hidden", as: "section", attributes: [h.AriaLabel("On call now")] }, + [ + vstack(h, { gap: "3", className: "p-4" }, [ + eyebrow(h, { as: "h2" }, ["On call now"]), + hstack( + h, + { gap: "6", wrap: true, align: "start", className: "gap-y-3" }, + Arr.map(schedules, (schedule) => stripItem(h, model, schedule)), + ), + ]), + ], + ), + ] +} + +/** "Delete {schedule}?": the 409 that names the chains paging it stays in the dialog. */ +export const deleteScheduleDialog = (h: H, model: Model): Html => { + const name = Option.getOrElse( + Option.map( + Arr.findFirst( + Option.getOrElse(AsyncData.getData(model.schedules.data), () => []), + (schedule) => schedule.id === model.confirmDeleteScheduleId, + ), + (schedule) => schedule.name, + ), + () => "schedule", + ) + return confirmDialog(h, { + slotId: DELETE_SCHEDULE_DIALOG_ID, + model: model.deleteScheduleDialog, + toParentMessage: (message) => Message.GotDeleteScheduleDialogMessage({ message }), + title: `Delete ${name}?`, + description: "Its overrides go with it. This cannot be undone.", + children: + model.deleteScheduleError === null + ? undefined + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't delete schedule"]), + alertDescription(h, [model.deleteScheduleError]), + ]), + ], + confirmLabel: "Delete schedule", + isPending: model.deletingScheduleId !== null, + onConfirm: Message.ClickedConfirmDeleteSchedule(), + }) +} + +/** The tab header's "New schedule", once one exists. */ +export const newScheduleButton = (h: H): Html => + linkButton(h, { size: "sm", variant: "outline", href: newScheduleHref() }, [ + icon(h, "PlusIcon"), + "New schedule", + ]) diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index 6bfed197a8..88da90dce0 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -29,6 +29,13 @@ import { CHAIN_DIALOG_ID } from "./update" import { rulesTab } from "./view-rules" import { deleteMonitorDialog, uptimeTab } from "./view-uptime" import { slosTab } from "./view-slos" +import { + deleteScheduleDialog, + hasSchedules, + newScheduleButton, + oncallTab, + whoIsOnCallStrip, +} from "./view-oncall" import { hstack, vstack } from "@maple/views/ui/stack" // `AlertsPage` and `AlertsHub`: the `/alerts` page and its tabs. @@ -40,6 +47,7 @@ const ALERTS_TABS: ReadonlyArray<{ value: AlertsTab; label: string }> = [ { value: "rules", label: "Rules" }, { value: "uptime", label: "Uptime" }, { value: "slos", label: "SLOs" }, + { value: "oncall", label: "On-call" }, { value: "routing", label: "Routing" }, ] @@ -53,6 +61,7 @@ const tabBar = ( ): Html => { const monitorCount = Option.map(AsyncData.getData(model.monitors.data), (monitors) => monitors.length) const sloCount = Option.map(AsyncData.getData(model.slos.data), (slos) => slos.length) + const scheduleCount = Option.map(AsyncData.getData(model.schedules.data), (rows) => rows.length) return linkTabStrip( h, "Alerts sections", @@ -62,15 +71,17 @@ const tabBar = ( ? Option.getOrUndefined(monitorCount) : tab.value === "slos" ? Option.getOrUndefined(sloCount) - : Option.match(overview, { - onNone: () => undefined, - onSome: (data) => - tab.value === "inbox" - ? data.openIncidents.length - : tab.value === "rules" - ? data.rules.length - : undefined, - }) + : tab.value === "oncall" + ? Option.getOrUndefined(scheduleCount) + : 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 }), @@ -195,6 +206,7 @@ const headerActions = (h: H, model: Model, tab: AlertsTab): Html => ), ] : []), + ...(canManage(model) && tab === "oncall" && hasSchedules(model) ? [newScheduleButton(h)] : []), ...(canManage(model) && tab === "routing" ? [ button( @@ -257,7 +269,9 @@ const hubBody = ( isManagedSignalType(incident.signalType), ) if (tab === "inbox") - return data.rules.length === 0 && !hasManagedIncidents ? emptyState : inboxTab(h, data) + return data.rules.length === 0 && !hasManagedIncidents + ? emptyState + : vstack(h, { gap: "6" }, [...whoIsOnCallStrip(h, model), inboxTab(h, data)]) return rulesTab(h, model, { data, destinations, @@ -312,17 +326,25 @@ export const view = Submodel.defineView((model, ? uptimeTab(h, model, model.nowMs ?? 0, inputs.context.pinnedNamespace) : tab === "slos" ? slosTab(h, model) - : hubBody(h, model, overview, tab), + : tab === "oncall" + ? oncallTab(h, model, model.nowMs ?? 0) + : hubBody(h, model, overview, tab), ], ), deleteDialog(h, model), deleteMonitorDialog(h, model), deleteChainDialog(h, model), + deleteScheduleDialog(h, model), h.submodel({ slotId: CHAIN_DIALOG_ID, model: model.chainDialog, view: ChainDialogView.view, - viewInputs: { id: CHAIN_DIALOG_ID, destinations: model.destinations }, + viewInputs: { + id: CHAIN_DIALOG_ID, + destinations: model.destinations, + members: model.members, + schedules: Option.getOrElse(AsyncData.getData(model.schedules.data), () => []), + }, toParentMessage: (message) => Message.GotChainDialogMessage({ message }), }), h.submodel({ diff --git a/apps/web/src/page/alerts/schedule-form/command.ts b/apps/web/src/page/alerts/schedule-form/command.ts new file mode 100644 index 0000000000..ffa4c6ff33 --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/command.ts @@ -0,0 +1,53 @@ +import { OnCallScheduleId } from "@maple/domain/http" +import { V2OnCallScheduleCreateParams } from "@maple/domain/http/v2" +import { Clock, Effect, Schema } from "effect" +import { Command } from "foldkit" +import { readGrants } from "../../../data/session-tenant" +import { browserZone, readSupportedTimeZones } from "../../../platform/time-zone" +import { retrieveOnCallSchedule, saveOnCallSchedule } from "../shared/oncall-schedules" +import { listOrgMembers } from "../shared/org-members" +import { Message } from "./message" + +/** The browser's zone (a new schedule's default), the clock and the zones to pick from. */ +export const ReadScheduleFormDefaults = Command.define("ReadScheduleFormDefaults", { + messages: [Message.ReadDefaults], + execute: Effect.map( + Effect.all([browserZone, Clock.currentTimeMillis, readSupportedTimeZones]), + ([timeZone, nowMs, zones]) => Message.ReadDefaults({ timeZone, nowMs, zones }), + ), +}) + +export const FetchExistingSchedule = Command.define("FetchScheduleFormSchedule", { + args: { key: Schema.String, scheduleId: OnCallScheduleId }, + messages: [Message.CompletedFetchExisting], + execute: ({ key, scheduleId }) => + retrieveOnCallSchedule(scheduleId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchExisting({ key, result })), + ), +}) + +export const FetchScheduleFormMembers = Command.define("FetchScheduleFormMembers", { + messages: [Message.CompletedFetchMembers], + execute: listOrgMembers.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchMembers({ result })), + ), +}) + +export const FetchScheduleFormSession = Command.define("FetchScheduleFormSession", { + args: { orgId: Schema.String }, + messages: [Message.CompletedFetchSession], + execute: ({ orgId }) => + Effect.map(readGrants, (grants) => Message.CompletedFetchSession({ orgId, grants })), +}) + +export const SaveSchedule = Command.define("SaveOnCallSchedule", { + args: { editingId: Schema.NullOr(OnCallScheduleId), payload: V2OnCallScheduleCreateParams }, + messages: [Message.CompletedSave], + execute: ({ editingId, payload }) => + saveOnCallSchedule(editingId, payload).pipe( + Effect.result, + Effect.map((result) => Message.CompletedSave({ result })), + ), +}) diff --git a/apps/web/src/page/alerts/schedule-form/form.ts b/apps/web/src/page/alerts/schedule-form/form.ts new file mode 100644 index 0000000000..974e9845bb --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/form.ts @@ -0,0 +1,304 @@ +import { + MAX_ONCALL_LAYERS, + MAX_ONCALL_PARTICIPANTS, + MAX_ONCALL_RESTRICTIONS, + OnCallLocalTime, + OnCallScheduleDescription, + OnCallScheduleName, + OnCallTimeZone, + OnCallWeekday, + UserId, +} from "@maple/domain/http" +import type { + V2OnCallLayer, + V2OnCallRestriction, + V2OnCallRotation, + V2OnCallSchedule, + V2OnCallScheduleCreateParams, +} from "@maple/domain/http/v2" +import { zonedDateParts, zonedPartsToEpochMs } from "@maple/query-engine/datetime" +import { Array as Arr, Option, Schema, String as Str } from "effect" +import { isoMs, msIso } from "../shared/time" + +// The schedule form's draft: name, zone and up to five layers, each with its rotation and +// restrictions, held as typed text and checked against the API's own schemas. + +export const RotationKind = Schema.Literals(["daily", "weekly", "custom"]) +export type RotationKind = typeof RotationKind.Type + +export const RestrictionDraft = Schema.Struct({ + key: Schema.String, + weekdays: Schema.Array(OnCallWeekday), + startTime: Schema.String, + endTime: Schema.String, +}) +export type RestrictionDraft = typeof RestrictionDraft.Type + +export const LayerDraft = Schema.Struct({ + key: Schema.String, + name: Schema.String, + /** In rotation order. */ + participants: Schema.Array(UserId), + rotation: RotationKind, + handoffWeekday: OnCallWeekday, + handoffTime: Schema.String, + lengthHours: Schema.String, + /** `YYYY-MM-DDTHH:MM` on the schedule zone's wall clock (a `datetime-local` value). */ + startsAt: Schema.String, + restrictions: Schema.Array(RestrictionDraft), +}) +export type LayerDraft = typeof LayerDraft.Type + +export const Form = Schema.Struct({ + name: Schema.String, + description: Schema.String, + timeZone: Schema.String, + layers: Schema.Array(LayerDraft), + /** The next key a layer or restriction takes, so rows keep identity across reorders. */ + nextKey: Schema.Number, +}) +export type Form = typeof Form.Type + +export const WEEKDAYS: ReadonlyArray> = [ + { value: "mon", label: "Mon" }, + { value: "tue", label: "Tue" }, + { value: "wed", label: "Wed" }, + { value: "thu", label: "Thu" }, + { value: "fri", label: "Fri" }, + { value: "sat", label: "Sat" }, + { value: "sun", label: "Sun" }, +] + +const pad = (n: number) => Str.padStart(2, "0")(String(n)) + +/** `ms` as a `datetime-local` value on `timeZone`'s wall clock. */ +export const localInputOf = (ms: number, timeZone: string): string => { + const parts = zonedDateParts(ms, timeZone) + return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}T${pad(parts.hour)}:${pad(parts.minute)}` +} + +const LOCAL_INPUT = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/u + +/** A `datetime-local` value read on `timeZone`'s wall clock, as epoch ms. */ +export const localInputMs = (value: string, timeZone: string): Option.Option => { + const match = LOCAL_INPUT.exec(value) + if (match === null || !isZone(timeZone)) return Option.none() + const [, year, month, day, hour, minute] = Arr.map(match, Number) + const ms = zonedPartsToEpochMs( + { + year: year ?? 0, + month: month ?? 1, + day: day ?? 1, + hour: hour ?? 0, + minute: minute ?? 0, + second: 0, + }, + timeZone, + ) + return Number.isFinite(ms) ? Option.some(ms) : Option.none() +} + +/** Today at 09:00 in the zone: where a new layer's rotation starts. */ +const defaultStart = (nowMs: number, timeZone: string): string => + `${Str.slice(0, 10)(localInputOf(nowMs, isZone(timeZone) ? timeZone : "UTC"))}T09:00` + +export const emptyLayer = (key: number, index: number, nowMs: number, timeZone: string): LayerDraft => ({ + key: `l${key}`, + name: index === 0 ? "Primary" : `Layer ${index + 1}`, + participants: [], + rotation: "weekly", + handoffWeekday: "mon", + handoffTime: "09:00", + lengthHours: "12", + startsAt: defaultStart(nowMs, timeZone), + restrictions: [], +}) + +export const emptyRestriction = (key: number): RestrictionDraft => ({ + key: `r${key}`, + weekdays: ["mon", "tue", "wed", "thu", "fri"], + startTime: "18:00", + endTime: "08:00", +}) + +export const emptyForm = (timeZone: string, nowMs: number): Form => ({ + name: "", + description: "", + timeZone, + layers: [emptyLayer(0, 0, nowMs, timeZone)], + nextKey: 1, +}) + +/** The form filled from a saved schedule; its starts read on the schedule's own zone. */ +export const formFromSchedule = (schedule: V2OnCallSchedule): Form => { + const layers = Arr.map(schedule.layers, (layer, layerIndex): LayerDraft => { + const rotation = layer.rotation + return { + key: `l${layerIndex}`, + name: layer.name, + participants: layer.participants, + rotation: rotation.kind, + handoffWeekday: rotation.kind === "weekly" ? rotation.handoff_weekday : "mon", + handoffTime: rotation.kind === "custom" ? "09:00" : rotation.handoff_time, + lengthHours: rotation.kind === "custom" ? String(rotation.length_hours) : "12", + startsAt: localInputOf(isoMs(layer.starts_at), schedule.time_zone), + restrictions: Arr.map(layer.restrictions ?? [], (restriction, index) => ({ + key: `r${layerIndex}x${index}`, + weekdays: restriction.weekdays, + startTime: restriction.start_time, + endTime: restriction.end_time, + })), + } + }) + return { + name: schedule.name, + description: schedule.description ?? "", + timeZone: schedule.time_zone, + layers, + nextKey: layers.length, + } +} + +// VALIDATION: the limits the API's schemas hold, as one message per field. + +const isName = Schema.is(OnCallScheduleName) +const isDescription = Schema.is(OnCallScheduleDescription) +const isZone = Schema.is(OnCallTimeZone) +const isLocalTime = Schema.is(OnCallLocalTime) +const isShiftLength = (raw: string) => { + const hours = Number(Str.trim(raw)) + return Str.trim(raw) !== "" && Number.isInteger(hours) && hours >= 1 && hours <= 336 +} + +export const nameError = (name: string): Option.Option => + Str.isEmpty(Str.trim(name)) + ? Option.some("Name the schedule") + : isName(Str.trim(name)) + ? Option.none() + : Option.some("Keep the name to 120 characters") + +export const descriptionError = (description: string): Option.Option => + isDescription(Str.trim(description)) + ? Option.none() + : Option.some("Keep the description to 500 characters") + +export const zoneError = (zone: string): Option.Option => + isZone(zone) ? Option.none() : Option.some("Pick a time zone from the list") + +/** One layer's field errors, by field. */ +export type LayerErrors = Readonly<{ + name: Option.Option + participants: Option.Option + handoff: Option.Option + startsAt: Option.Option + restrictions: ReadonlyArray> +}> + +const restrictionError = (restriction: RestrictionDraft): Option.Option => + restriction.weekdays.length === 0 + ? Option.some("Pick at least one day") + : !isLocalTime(restriction.startTime) || !isLocalTime(restriction.endTime) + ? Option.some("Use HH:MM times") + : Option.none() + +export const layerErrors = (layer: LayerDraft, timeZone: string): LayerErrors => { + const name = Str.trim(layer.name) + return { + name: + name === "" + ? Option.some("Name the layer") + : name.length > 100 + ? Option.some("Keep the layer name to 100 characters") + : Option.none(), + participants: + layer.participants.length === 0 + ? Option.some("Add at least one person") + : layer.participants.length > MAX_ONCALL_PARTICIPANTS + ? Option.some(`A layer rotates at most ${MAX_ONCALL_PARTICIPANTS} people`) + : Option.none(), + handoff: + layer.rotation === "custom" + ? isShiftLength(layer.lengthHours) + ? Option.none() + : Option.some("Shifts last 1 to 336 whole hours") + : isLocalTime(layer.handoffTime) + ? Option.none() + : Option.some("Use an HH:MM handoff time"), + startsAt: Option.isSome(localInputMs(layer.startsAt, timeZone)) + ? Option.none() + : Option.some("Pick when the rotation starts"), + restrictions: Arr.map(layer.restrictions, restrictionError), + } +} + +const hasLayerError = (errors: LayerErrors) => + Option.isSome(errors.name) || + Option.isSome(errors.participants) || + Option.isSome(errors.handoff) || + Option.isSome(errors.startsAt) || + Arr.some(errors.restrictions, Option.isSome) + +/** What still blocks saving, one short phrase each, for the action bar. */ +export const formIssues = (form: Form): ReadonlyArray => [ + ...(Option.isSome(nameError(form.name)) ? ["Name"] : []), + ...(Option.isSome(descriptionError(form.description)) ? ["Description"] : []), + ...(Option.isSome(zoneError(form.timeZone)) ? ["Time zone"] : []), + ...(form.layers.length === 0 ? ["At least one layer"] : []), + ...Arr.flatMap(form.layers, (layer, index) => + hasLayerError(layerErrors(layer, form.timeZone)) ? [`Layer ${index + 1}`] : [], + ), +] + +const rotationOf = (layer: LayerDraft): V2OnCallRotation => + layer.rotation === "daily" + ? { kind: "daily", handoff_time: layer.handoffTime } + : layer.rotation === "weekly" + ? { kind: "weekly", handoff_weekday: layer.handoffWeekday, handoff_time: layer.handoffTime } + : { kind: "custom", length_hours: Number(Str.trim(layer.lengthHours)) } + +const restrictionOf = (restriction: RestrictionDraft): Option.Option => + Arr.isReadonlyArrayNonEmpty(restriction.weekdays) + ? Option.some({ + weekdays: restriction.weekdays, + start_time: restriction.startTime, + end_time: restriction.endTime, + }) + : Option.none() + +const layerOf = (layer: LayerDraft, timeZone: string): Option.Option => + Option.flatMap(localInputMs(layer.startsAt, timeZone), (startMs) => { + const participants = layer.participants + return Arr.isReadonlyArrayNonEmpty(participants) + ? Option.map( + Option.all(Arr.map(layer.restrictions, restrictionOf)), + (restrictions): V2OnCallLayer => ({ + name: Str.trim(layer.name), + participants, + rotation: rotationOf(layer), + starts_at: msIso(startMs), + ...(restrictions.length === 0 ? undefined : { restrictions }), + }), + ) + : Option.none() + }) + +/** The create payload the form describes, or none while anything blocks saving. */ +export const payloadOf = (form: Form): Option.Option => + formIssues(form).length > 0 || form.layers.length > MAX_ONCALL_LAYERS + ? Option.none() + : Option.flatMap( + Option.all(Arr.map(form.layers, (layer) => layerOf(layer, form.timeZone))), + (layers) => + Arr.isReadonlyArrayNonEmpty(layers) + ? Option.some({ + name: Str.trim(form.name), + description: Str.isEmpty(Str.trim(form.description)) + ? null + : Str.trim(form.description), + time_zone: form.timeZone, + layers, + }) + : Option.none(), + ) + +export { MAX_ONCALL_LAYERS, MAX_ONCALL_RESTRICTIONS } diff --git a/apps/web/src/page/alerts/schedule-form/index.ts b/apps/web/src/page/alerts/schedule-form/index.ts new file mode 100644 index 0000000000..6dcd7b46cc --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/index.ts @@ -0,0 +1,23 @@ +import { definePage } from "../../contract" +import { oncallTabHref } from "../shared/oncall-schedules" +import { Message } from "./message" +import { Model } from "./model" +import { init, update } from "./update" +import { view } from "./view" + +/** `/alerts/schedules/new`: one instance per schedule being edited, or the new one. */ +export const page = definePage( + "AlertsScheduleNew", + { Model, Message }, + { + routes: ["AlertsScheduleNew"], + key: (route) => route.search.scheduleId ?? "new", + init, + update, + view, + breadcrumbs: () => [ + { label: "Alerts", href: "/alerts" }, + { label: "On-call", href: oncallTabHref() }, + ], + }, +) diff --git a/apps/web/src/page/alerts/schedule-form/message.ts b/apps/web/src/page/alerts/schedule-form/message.ts new file mode 100644 index 0000000000..fff133f99b --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/message.ts @@ -0,0 +1,50 @@ +import * as Policy from "@maple/domain/policy" +import { OnCallWeekday, UserId } from "@maple/domain/http" +import { V2OnCallSchedule } from "@maple/domain/http/v2" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Combobox from "@maple/views/ui/combobox" +import * as Menu from "@maple/views/ui/menu" +import { OrgMember } from "../shared/org-members" +import { RotationKind } from "./form" + +const outcome = (success: S) => Schema.Result(success, LoadFailure) + +export const Direction = Schema.Literals(["up", "down"]) +export type Direction = typeof Direction.Type + +/** A layer's text fields. */ +export const LayerField = Schema.Literals(["name", "handoffTime", "lengthHours", "startsAt"]) +export type LayerField = typeof LayerField.Type + +export const Message = defineMessageUnion({ + ReadDefaults: { timeZone: Schema.String, nowMs: Schema.Number, zones: Schema.Array(Schema.String) }, + CompletedFetchExisting: { key: Schema.String, result: outcome(V2OnCallSchedule) }, + CompletedFetchMembers: { result: Schema.Result(Schema.Array(OrgMember), Schema.String) }, + CompletedFetchSession: { orgId: Schema.String, grants: Schema.Array(Policy.PermissionName) }, + ChangedName: { value: Schema.String }, + ChangedDescription: { value: Schema.String }, + GotZoneMessage: { message: Combobox.Message }, + ClickedAddLayer: {}, + ClickedRemoveLayer: { key: Schema.String }, + ClickedMoveLayer: { key: Schema.String, direction: Direction }, + ChangedLayerText: { key: Schema.String, field: LayerField, value: Schema.String }, + PickedRotation: { key: Schema.String, rotation: RotationKind }, + PickedHandoffWeekday: { key: Schema.String, weekday: OnCallWeekday }, + GotParticipantMenuMessage: { key: Schema.String, message: Menu.Message }, + ClickedRemoveParticipant: { key: Schema.String, userId: UserId }, + ClickedMoveParticipant: { key: Schema.String, userId: UserId, direction: Direction }, + ClickedAddRestriction: { key: Schema.String }, + ClickedRemoveRestriction: { key: Schema.String, restrictionKey: Schema.String }, + ToggledRestrictionDay: { key: Schema.String, restrictionKey: Schema.String, weekday: OnCallWeekday }, + ChangedRestrictionTime: { + key: Schema.String, + restrictionKey: Schema.String, + edge: Schema.Literals(["start", "end"]), + value: Schema.String, + }, + ClickedSave: {}, + CompletedSave: { result: outcome(V2OnCallSchedule) }, +}) +export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/schedule-form/model.ts b/apps/web/src/page/alerts/schedule-form/model.ts new file mode 100644 index 0000000000..f7b5e06669 --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/model.ts @@ -0,0 +1,42 @@ +import * as Policy from "@maple/domain/policy" +import { OnCallScheduleId } from "@maple/domain/http" +import { V2OnCallSchedule } from "@maple/domain/http/v2" +import { Schema } from "effect" +import { KeyedSchema } from "@maple/views/data/keyed" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Combobox from "@maple/views/ui/combobox" +import * as Menu from "@maple/views/ui/menu" +import { AlertsScheduleNewSearch } from "../../../route/search" +import { OrgMember } from "../shared/org-members" +import { Form } from "./form" + +// `/alerts/schedules/new`: create an on-call schedule, or edit one (`?scheduleId=`). + +export const Model = Schema.Struct({ + search: AlertsScheduleNewSearch, + /** The schedule being edited; `null` creates. */ + editingId: Schema.NullOr(OnCallScheduleId), + /** The edited schedule's read; the form fills from it once. */ + existing: KeyedSchema(V2OnCallSchedule, LoadFailure), + form: Form, + /** The browser's zone and clock arrived: a new form starts from them. */ + isReady: Schema.Boolean, + nowMs: Schema.Number, + /** Every IANA zone the browser knows, for the zone picker. */ + zones: Schema.Array(Schema.String), + zonePicker: Combobox.Model, + members: Schema.NullOr(Schema.Array(OrgMember)), + membersError: Schema.NullOr(Schema.String), + /** Each layer's "Add person" menu, by layer key. */ + menus: Schema.Record(Schema.String, Menu.Model), + isSaving: Schema.Boolean, + /** A save was tried: each field shows what is wrong with it. */ + isShowingErrors: Schema.Boolean, + saveFailure: Schema.NullOr(LoadFailure), + grants: Schema.Array(Policy.PermissionName), + isSessionLoaded: Schema.Boolean, +}) +export type Model = typeof Model.Type + +/** The viewer may change schedules (`alerts:manage`); the API refuses anyone else. */ +export const canManage = (model: Pick): boolean => Policy.has(model.grants, "alerts:manage") diff --git a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts new file mode 100644 index 0000000000..3bd89764ce --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts @@ -0,0 +1,151 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { UserId } from "@maple/domain/http" +import { Option, Result, Schema, Array as Arr } from "effect" +import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import type { PageContext } from "../../contract" +import { + checkoutSchedule, + memberId, + ONCALL_NOW, + oncallMembers, + schedulePublicId, +} from "../shared/oncall-test-fixtures" +import { ORG } from "../shared/test-rows" +import { formFromSchedule, localInputMs, payloadOf } from "./form" +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 names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) +const run = (model: Model, messages: ReadonlyArray) => + Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) + +const ENGINEER = Policy.grantsOf(["org:engineer"]) +const ZONES = ["UTC", "Europe/Berlin", "America/New_York"] + +const ready = (search: { scheduleId?: string } = {}) => { + const entered = init({ _tag: "AlertsScheduleNew", search }, context) + const base = run(entered.model, [ + Message.ReadDefaults({ timeZone: "Europe/Berlin", nowMs: ONCALL_NOW, zones: ZONES }), + Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), + ]) + return search.scheduleId === undefined + ? base + : run(base, [ + Message.CompletedFetchExisting({ + key: base.existing.key ?? "", + result: Result.succeed(checkoutSchedule(ONCALL_NOW)), + }), + ]) +} + +const addPerson = (key: string, index: number) => + Message.GotParticipantMenuMessage({ + key, + message: { _tag: "ClickedItem", index, item: { kind: "Item", value: memberId(index) } }, + }) + +describe("schedule form", () => { + test("a new schedule starts on the viewer's zone, today at 09:00, with one weekly layer", () => { + const model = ready() + expect(model.form.timeZone).toBe("Europe/Berlin") + expect(model.form.layers).toHaveLength(1) + expect(model.form.layers[0]?.startsAt).toBe("2026-11-10T09:00") + scene( + { update: pageUpdate, view }, + given(model), + expectView(text("New schedule")).toExist(), + expectView(role("combobox", { name: "Time zone" })).toExist(), + expectView(text("Nobody yet. Add who takes turns.")).toExist(), + ) + }) + + test("an edited schedule fills the form and saves back the layers it was read with", () => { + const schedule = checkoutSchedule(ONCALL_NOW) + const model = ready({ scheduleId: schedulePublicId(1) }) + expect(model.form).toEqual(formFromSchedule(schedule)) + const payload = payloadOf(model.form) + expect(Option.map(payload, (p) => p.layers)).toEqual(Option.some(schedule.layers)) + expect(Option.map(payload, (p) => p.time_zone)).toEqual(Option.some("Europe/Berlin")) + }) + + test("a start reads on the schedule's wall clock, across a DST change", () => { + expect(localInputMs("2026-10-25T09:00", "Europe/Berlin")).toEqual( + Option.some(Date.parse("2026-10-25T08:00:00.000Z")), + ) + expect(localInputMs("2026-10-24T09:00", "Europe/Berlin")).toEqual( + Option.some(Date.parse("2026-10-24T07:00:00.000Z")), + ) + expect(localInputMs("tomorrow", "Europe/Berlin")).toEqual(Option.none()) + }) + + test("saving with what the API refuses shows each problem and sends nothing", () => { + const model = run(ready(), [ + Message.ChangedLayerText({ key: "l0", field: "handoffTime", value: "25:00" }), + Message.ClickedAddRestriction({ key: "l0" }), + ...Arr.map(["mon", "tue", "wed", "thu", "fri"] as const, (weekday) => + Message.ToggledRestrictionDay({ key: "l0", restrictionKey: "r1", weekday }), + ), + ]) + const step = pageUpdate(model, Message.ClickedSave()) + expect(names(step.commands)).toEqual([]) + scene( + { update: pageUpdate, view }, + given(step.model), + expectView(text("Name the schedule")).toExist(), + expectView(text("Add at least one person")).toExist(), + expectView(text("Use an HH:MM handoff time")).toExist(), + expectView(text("Pick at least one day")).toExist(), + ) + }) + + test("people rotate in the order picked, and move up and down", () => { + const model = run(ready(), [ + Message.ChangedName({ value: "Checkout primary" }), + addPerson("l0", 0), + addPerson("l0", 1), + addPerson("l0", 2), + Message.ClickedMoveParticipant({ + key: "l0", + userId: Schema.decodeSync(UserId)(memberId(2)), + direction: "up", + }), + ]) + expect(model.form.layers[0]?.participants).toEqual([memberId(0), memberId(2), memberId(1)]) + expect(names(pageUpdate(model, Message.ClickedSave()).commands)).toEqual(["SaveOnCallSchedule"]) + }) + + test("a refused save keeps the form and says why", () => { + const saving = pageUpdate(run(ready({ scheduleId: schedulePublicId(1) }), []), Message.ClickedSave()) + expect(names(saving.commands)).toEqual(["SaveOnCallSchedule"]) + const failed = pageUpdate( + saving.model, + Message.CompletedSave({ + result: Result.fail({ + _tag: "@maple/http/errors/AlertValidationError", + type: "invalid_request_error", + code: "invalid_request", + title: "Invalid request", + message: "Omar Haddad is no longer a member of this organization.", + retryable: false, + recovery: "fix_request", + }), + }), + ) + scene( + { update: pageUpdate, view }, + given(failed.model), + expectView(text("Couldn't save the schedule")).toExist(), + expectView(text("Omar Haddad is no longer a member of this organization.")).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/schedule-form/update.ts b/apps/web/src/page/alerts/schedule-form/update.ts new file mode 100644 index 0000000000..4c1b11ef82 --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/update.ts @@ -0,0 +1,295 @@ +import { UserId } from "@maple/domain/http" +import { Option, Result, Schema, Array as Arr } from "effect" +import { AsyncData, Command as FoldkitCommand } from "foldkit" +import * as Keyed from "@maple/views/data/keyed" +import { requestKey } from "@maple/views/data/request-key" +import * as Combobox from "@maple/views/ui/combobox" +import * as Menu from "@maple/views/ui/menu" +import type { AppRoute } from "../../../route" +import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" +import { scheduleHref, scheduleIdFromUrl } from "../shared/oncall-schedules" +import { + FetchExistingSchedule, + FetchScheduleFormMembers, + FetchScheduleFormSession, + ReadScheduleFormDefaults, + SaveSchedule, +} from "./command" +import { + emptyForm, + emptyLayer, + emptyRestriction, + type Form, + formFromSchedule, + type LayerDraft, + MAX_ONCALL_LAYERS, + MAX_ONCALL_RESTRICTIONS, + payloadOf, + type RestrictionDraft, +} from "./form" +import { type Direction, Message } from "./message" +import { canManage, type Model } from "./model" + +type Return = PageReturn +type FormRoute = Extract + +export const ZONE_PICKER_ID = "schedule-form-zone" + +export const participantMenuId = (layerKey: string) => `schedule-form-people-${layerKey}` + +export const init = (route: FormRoute, context: PageContext): Return => { + const editingId = Option.getOrNull( + Option.flatMap(Option.fromNullishOr(route.search.scheduleId), scheduleIdFromUrl), + ) + const model: Model = { + search: route.search, + editingId, + existing: Keyed.idle(), + form: emptyForm("UTC", 0), + isReady: false, + nowMs: 0, + zones: [], + zonePicker: Combobox.init(ZONE_PICKER_ID, "single", { selected: ["UTC"] }), + members: null, + membersError: null, + menus: {}, + isSaving: false, + isShowingErrors: false, + saveFailure: null, + grants: [], + isSessionLoaded: false, + } + const common = [ + ReadScheduleFormDefaults(), + FetchScheduleFormMembers(), + FetchScheduleFormSession({ orgId: context.orgId }), + ] + if (editingId === null) return { model, commands: common } + const key = requestKey(context.orgId, "oncallSchedules.retrieve", { id: editingId }) + return Option.match(Keyed.request(model.existing, key, "Load"), { + onNone: () => ({ model, commands: common }), + onSome: (existing) => ({ + model: { ...model, existing }, + commands: [...common, FetchExistingSchedule({ key, scheduleId: editingId })], + }), + }) +} + +const withForm = (model: Model, form: Form): Return => ({ model: { ...model, form } }) + +/** The zone picker reads the form's zone again (a load, a new default). */ +const syncZone = (model: Model): Model => ({ + ...model, + zonePicker: Combobox.init(ZONE_PICKER_ID, "single", { selected: [model.form.timeZone] }), +}) + +const editLayer = (model: Model, key: string, edit: (layer: LayerDraft) => LayerDraft): Return => + withForm(model, { + ...model.form, + layers: Arr.map(model.form.layers, (layer) => (layer.key === key ? edit(layer) : layer)), + }) + +const editRestriction = ( + model: Model, + key: string, + restrictionKey: string, + edit: (restriction: RestrictionDraft) => RestrictionDraft, +): Return => + editLayer(model, key, (layer) => ({ + ...layer, + restrictions: Arr.map(layer.restrictions, (restriction) => + restriction.key === restrictionKey ? edit(restriction) : restriction, + ), + })) + +/** Swaps an item with its neighbour; at either end the list stays as it is. */ +export const moved = (items: ReadonlyArray, index: number, direction: Direction): ReadonlyArray => { + const to = direction === "up" ? index - 1 : index + 1 + const moving = items[index] + const other = items[to] + if (moving === undefined || other === undefined) return items + return Arr.map(items, (item, at) => (at === index ? other : at === to ? moving : item)) +} + +const swapIndex = (items: ReadonlyArray, find: (item: A) => boolean, direction: Direction) => + Option.match(Arr.findFirstIndex(items, find), { + onNone: () => items, + onSome: (index) => moved(items, index, direction), + }) + +const decodeUserId = Schema.decodeUnknownOption(UserId) + +/** A layer's "Add person" menu: a pick appends the member to the rotation. */ +const participantMenu = (model: Model, key: string, message: Menu.Message): Return => { + const result = Menu.update(model.menus[key] ?? Menu.init(participantMenuId(key)), message) + const next: Model = { ...model, menus: { ...model.menus, [key]: result.model } } + const commands = FoldkitCommand.mapMessages(result.commands ?? [], (child) => + Message.GotParticipantMenuMessage({ key, message: child }), + ) + const picked = Option.flatMap( + result.outMessage?._tag === "SelectedItem" ? Option.some(result.outMessage.value) : Option.none(), + decodeUserId, + ) + return Option.match(picked, { + onNone: () => ({ model: next, commands }), + onSome: (userId) => ({ + ...editLayer(next, key, (layer) => + layer.participants.includes(userId) + ? layer + : { ...layer, participants: [...layer.participants, userId] }, + ), + commands, + }), + }) +} + +const zonePicked = (model: Model, message: Combobox.Message): Return => { + const step = Combobox.update(model.zonePicker, message) + const commands = FoldkitCommand.mapMessages(step.commands ?? [], (child) => + Message.GotZoneMessage({ message: child }), + ) + const zone = + step.outMessage?._tag === "ChangedValue" && model.zones.includes(step.outMessage.value) + ? step.outMessage.value + : null + return { + model: { + ...model, + zonePicker: step.model, + form: zone === null ? model.form : { ...model.form, timeZone: zone }, + }, + commands, + } +} + +const save = (model: Model): Return => + model.isSaving || !canManage(model) + ? { model } + : Option.match(payloadOf(model.form), { + onNone: () => ({ model: { ...model, isShowingErrors: true } }), + onSome: (payload) => ({ + model: { ...model, isSaving: true, isShowingErrors: true, saveFailure: null }, + commands: [SaveSchedule({ editingId: model.editingId, payload })], + }), + }) + +const addRestriction = (model: Model, key: string): Return => { + const added = editLayer(model, key, (layer) => + layer.restrictions.length >= MAX_ONCALL_RESTRICTIONS + ? layer + : { ...layer, restrictions: [...layer.restrictions, emptyRestriction(model.form.nextKey)] }, + ) + return withForm(added.model, { ...added.model.form, nextKey: added.model.form.nextKey + 1 }) +} + +export const update = (model: Model, message: Message, context: PageContext): Return => + Message.match(message, { + ReadDefaults: ({ timeZone, nowMs, zones }) => { + const ready: Model = { ...model, nowMs, zones, isReady: true } + // An edit fills from the schedule; a new one starts on the viewer's zone and today. + return model.editingId !== null + ? { model: ready } + : { model: syncZone({ ...ready, form: emptyForm(timeZone, nowMs) }) } + }, + CompletedFetchExisting: ({ key, result }) => { + const existing = Keyed.accept(model.existing, key, result) + const loaded = Option.getOrNull(AsyncData.getData(existing.data)) + return { + model: + loaded === null + ? { ...model, existing } + : syncZone({ ...model, existing, form: formFromSchedule(loaded) }), + } + }, + CompletedFetchMembers: ({ result }) => + Result.match(result, { + onSuccess: (members) => ({ model: { ...model, members, membersError: null } }), + onFailure: (error) => ({ model: { ...model, members: [], membersError: error } }), + }), + CompletedFetchSession: ({ orgId, grants }) => + orgId === context.orgId ? { model: { ...model, grants, isSessionLoaded: true } } : { model }, + ChangedName: ({ value }) => withForm(model, { ...model.form, name: value }), + ChangedDescription: ({ value }) => withForm(model, { ...model.form, description: value }), + GotZoneMessage: ({ message: child }) => zonePicked(model, child), + ClickedAddLayer: () => + model.form.layers.length >= MAX_ONCALL_LAYERS + ? { model } + : withForm(model, { + ...model.form, + layers: [ + ...model.form.layers, + emptyLayer( + model.form.nextKey, + model.form.layers.length, + model.nowMs, + model.form.timeZone, + ), + ], + nextKey: model.form.nextKey + 1, + }), + ClickedRemoveLayer: ({ key }) => + withForm(model, { + ...model.form, + layers: Arr.filter(model.form.layers, (layer) => layer.key !== key), + }), + ClickedMoveLayer: ({ key, direction }) => + withForm(model, { + ...model.form, + layers: swapIndex(model.form.layers, (layer) => layer.key === key, direction), + }), + ChangedLayerText: ({ key, field, value }) => + editLayer(model, key, (layer) => ({ ...layer, [field]: value })), + PickedRotation: ({ key, rotation }) => editLayer(model, key, (layer) => ({ ...layer, rotation })), + PickedHandoffWeekday: ({ key, weekday }) => + editLayer(model, key, (layer) => ({ ...layer, handoffWeekday: weekday })), + GotParticipantMenuMessage: ({ key, message: child }) => participantMenu(model, key, child), + ClickedRemoveParticipant: ({ key, userId }) => + editLayer(model, key, (layer) => ({ + ...layer, + participants: Arr.filter(layer.participants, (id) => id !== userId), + })), + ClickedMoveParticipant: ({ key, userId, direction }) => + editLayer(model, key, (layer) => ({ + ...layer, + participants: swapIndex(layer.participants, (id) => id === userId, direction), + })), + ClickedAddRestriction: ({ key }) => addRestriction(model, key), + ClickedRemoveRestriction: ({ key, restrictionKey }) => + editLayer(model, key, (layer) => ({ + ...layer, + restrictions: Arr.filter( + layer.restrictions, + (restriction) => restriction.key !== restrictionKey, + ), + })), + ToggledRestrictionDay: ({ key, restrictionKey, weekday }) => + editRestriction(model, key, restrictionKey, (restriction) => ({ + ...restriction, + weekdays: restriction.weekdays.includes(weekday) + ? Arr.filter(restriction.weekdays, (day) => day !== weekday) + : [...restriction.weekdays, weekday], + })), + ChangedRestrictionTime: ({ key, restrictionKey, edge, value }) => + editRestriction(model, key, restrictionKey, (restriction) => + edge === "start" ? { ...restriction, startTime: value } : { ...restriction, endTime: value }, + ), + ClickedSave: () => save(model), + CompletedSave: ({ result }) => + Result.match(result, { + onSuccess: (schedule): Return => ({ + model: { ...model, isSaving: false }, + outMessage: PageOutMessage.RequestedNavigation({ + url: scheduleHref(schedule.id), + isReplace: true, + toast: { + type: "success", + title: model.editingId === null ? "Schedule created" : "Changes saved", + description: "", + }, + }), + }), + onFailure: (failure): Return => ({ + model: { ...model, isSaving: false, saveFailure: failure }, + }), + }), + }) diff --git a/apps/web/src/page/alerts/schedule-form/view.ts b/apps/web/src/page/alerts/schedule-form/view.ts new file mode 100644 index 0000000000..1048f83c5a --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/view.ts @@ -0,0 +1,529 @@ +import { Option, Array as Arr } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureMessage } from "@maple/views/data/load-failure" +import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" +import { button, linkButton } from "@maple/views/ui/button" +import * as ComboboxView from "@maple/views/ui/combobox-view" +import { errorState } from "@maple/views/ui/error-state" +import { fieldErrorText } from "@maple/views/ui/field" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" +import { input } from "@maple/views/ui/input" +import { label } from "@maple/views/ui/label" +import * as Menu from "@maple/views/ui/menu" +import type { MenuEntry } from "@maple/views/ui/menu-view" +import { nativeOption, nativeSelect } from "@maple/views/ui/native-select" +import { panel } from "@maple/views/ui/panel" +import { personAvatar } from "@maple/views/ui/person-avatar" +import { multiSegmentedSelect, segmentedSelect } from "@maple/views/ui/segmented-select" +import { skeleton } from "@maple/views/ui/skeleton" +import { hstack, vstack } from "@maple/views/ui/stack" +import { textarea } from "@maple/views/ui/textarea" +import { typography, typographyClass } from "@maple/views/ui/typography" +import type { PageViewInputs } from "../../contract" +import { dashboardPage } from "../../kit/dashboard-page" +import { + formActionBar, + formCard, + formScrollArea, + formSection, + managersOnlyPage, +} from "../../kit/form-section" +import { pickList } from "../../kit/pick-list" +import { oncallTabHref, scheduleHref } from "../shared/oncall-schedules" +import { memberName, memberOf } from "../shared/org-members" +import { + descriptionError, + formIssues, + type LayerDraft, + layerErrors, + MAX_ONCALL_LAYERS, + MAX_ONCALL_RESTRICTIONS, + nameError, + type RestrictionDraft, + RotationKind, + WEEKDAYS, + zoneError, +} from "./form" +import { type LayerField, Message } from "./message" +import { canManage, type Model } from "./model" +import { participantMenuId } from "./update" + +// `/alerts/schedules/new`: the schedule's name and zone, then its layers: who rotates, how they +// hand off, from when, and the weekly windows a layer is limited to. + +type H = HtmlBuilder + +const FORM_WIDTH = "max-w-3xl" + +/** A field's error line once a save was tried. */ +const shown = (h: H, model: Model, error: Option.Option): ReadonlyArray => + model.isShowingErrors + ? Option.match(error, { + onNone: () => [], + onSome: (text) => [fieldErrorText(h, { attributes: [h.Role("alert")] }, text)], + }) + : [] + +const fieldLabel = (h: H, id: string, text: string): Html => + label(h, { className: typographyClass({ size: "xs", tone: "muted" }), attributes: [h.For(id)] }, [text]) + +/** A labelled input bound to one value. */ +const textInput = ( + h: H, + options: Readonly<{ + id: string + label: string + value: string + type?: string + placeholder?: string + className?: string + /** The section's title already names it: the label is for screen readers. */ + isLabelHidden?: boolean + onInput: (value: string) => Message + }>, +): Html => + vstack(h, { gap: "1.5", className: options.className }, [ + options.isLabelHidden === true + ? label(h, { className: "sr-only", attributes: [h.For(options.id)] }, [options.label]) + : fieldLabel(h, options.id, options.label), + input(h, { + id: options.id, + type: options.type, + attributes: [ + h.Value(options.value), + h.OnInput(options.onInput), + h.Attribute("autocomplete", "off"), + ...(options.placeholder === undefined ? [] : [h.Placeholder(options.placeholder)]), + ], + }), + ]) + +const nameSection = (h: H, model: Model): Html => + formSection(h, { title: "Name" }, [ + textInput(h, { + id: "schedule-name", + label: "Name", + isLabelHidden: true, + value: model.form.name, + placeholder: "Checkout primary", + onInput: (value) => Message.ChangedName({ value }), + }), + ...shown(h, model, nameError(model.form.name)), + vstack(h, { gap: "1.5" }, [ + fieldLabel(h, "schedule-description", "Description (optional)"), + textarea(h, { + id: "schedule-description", + defaultValue: model.form.description, + attributes: [ + h.Rows(2), + h.Placeholder("Who is on this rotation and how to swap a shift"), + h.OnInput((value) => Message.ChangedDescription({ value })), + ], + }), + ]), + ...shown(h, model, descriptionError(model.form.description)), + ]) + +/** The schedule's zone: a searchable list of every IANA zone, the viewer's own first by default. */ +const zoneSection = (h: H, model: Model): Html => + formSection(h, { title: "Time zone", hint: "Handoffs and restrictions read in it" }, [ + vstack(h, { gap: "1.5", className: "max-w-sm" }, [ + label(h, { className: "sr-only", attributes: [h.For(`${model.zonePicker.id}-input`)] }, [ + "Time zone", + ]), + h.submodel({ + slotId: model.zonePicker.id, + model: model.zonePicker, + view: ComboboxView.view, + toParentMessage: (message) => Message.GotZoneMessage({ message }), + viewInputs: { + items: model.zones, + ariaLabel: "Time zone", + placeholder: "Search time zones…", + emptyText: "No time zone matches that", + startAddon: icon(h, "GlobeIcon"), + className: "w-full", + highlightsSelected: true, + toView: ({ field, rest }) => h.div([h.Class("contents")], [field, ...rest]), + }, + }), + ]), + ...shown(h, model, zoneError(model.form.timeZone)), + ]) + +/** The rotation's people, in order, with an "Add person" menu of the rest. */ +const participantsField = (h: H, model: Model, layer: LayerDraft, index: number): Html => { + const key = layer.key + const count = layer.participants.length + const addEntries: ReadonlyArray = Arr.flatMap(model.members ?? [], (member) => + layer.participants.includes(member.userId) + ? [] + : [ + { + _tag: "Item", + value: member.userId, + label: member.name, + content: [ + hstack(h, { gap: "2", shrink: true, as: "span" }, [ + personAvatar(h, { name: member.name, imageUrl: member.imageUrl, size: "xs" }), + typography(h, { truncate: true, className: "min-w-0 flex-1" }, [member.name]), + ]), + ], + }, + ], + ) + return vstack(h, { gap: "1.5" }, [ + typography(h, { size: "xs", tone: "muted", as: "p" }, ["People, in rotation order"]), + pickList(h, { + entries: Arr.map(layer.participants, (userId, at) => { + const name = memberName(model.members, userId) + return { + key: userId, + label: name, + media: personAvatar(h, { + name, + size: "xs", + imageUrl: Option.getOrUndefined( + Option.map(memberOf(model.members, userId), (member) => member.imageUrl), + ), + }), + removeLabel: `Remove ${name} from layer ${index + 1}`, + onRemove: Message.ClickedRemoveParticipant({ key, userId }), + move: { + up: + at === 0 + ? null + : Message.ClickedMoveParticipant({ key, userId, direction: "up" }), + down: + at === count - 1 + ? null + : Message.ClickedMoveParticipant({ key, userId, direction: "down" }), + }, + } + }), + emptyText: + model.membersError === null + ? "Nobody yet. Add who takes turns." + : `Couldn't load members: ${model.membersError}`, + menu: model.menus[key] ?? Menu.init(participantMenuId(key)), + toMenuMessage: (message) => Message.GotParticipantMenuMessage({ key, message }), + addEntries, + addLabel: "Add person", + }), + ]) +} + +const ROTATIONS: ReadonlyArray> = [ + { value: "daily", label: "Daily" }, + { value: "weekly", label: "Weekly" }, + { value: "custom", label: "Custom" }, +] + +const layerText = (key: string, field: LayerField) => (value: string) => + Message.ChangedLayerText({ key, field, value }) + +/** How the layer hands off: the kind, then its day and time or its shift length. */ +const rotationField = (h: H, layer: LayerDraft): Html => { + const key = layer.key + return vstack(h, { gap: "2" }, [ + typography(h, { size: "xs", tone: "muted", as: "p" }, ["Hand off"]), + segmentedSelect(h, { + ariaLabel: "Rotation", + size: "sm", + className: "w-fit", + value: layer.rotation, + onChange: (rotation) => Message.PickedRotation({ key, rotation }), + options: ROTATIONS, + }), + hstack(h, { gap: "3", wrap: true, align: "end" }, [ + ...(layer.rotation === "weekly" + ? [ + vstack(h, { gap: "1.5" }, [ + fieldLabel(h, `${key}-weekday`, "Day"), + nativeSelect( + h, + { + defaultValue: layer.handoffWeekday, + className: "w-32", + attributes: [ + h.Key(`${key}-weekday`), + h.Id(`${key}-weekday`), + h.OnChange((value) => + Option.match( + Arr.findFirst(WEEKDAYS, (day) => day.value === value), + { + onNone: () => + Message.PickedHandoffWeekday({ + key, + weekday: layer.handoffWeekday, + }), + onSome: (day) => + Message.PickedHandoffWeekday({ + key, + weekday: day.value, + }), + }, + ), + ), + ], + }, + Arr.map(WEEKDAYS, (day) => nativeOption(day.value, day.label)), + ), + ]), + ] + : []), + ...(layer.rotation === "custom" + ? [ + textInput(h, { + id: `${key}-length`, + label: "Every, in hours", + type: "number", + value: layer.lengthHours, + className: "w-32", + onInput: layerText(key, "lengthHours"), + }), + ] + : [ + textInput(h, { + id: `${key}-handoff`, + label: "At", + type: "time", + value: layer.handoffTime, + className: "w-32", + onInput: layerText(key, "handoffTime"), + }), + ]), + ]), + ]) +} + +/** One weekly window: its days, from and to (an end at or before the start crosses midnight). */ +const restrictionRow = (h: H, layer: LayerDraft, restriction: RestrictionDraft): Html => { + const key = layer.key + const restrictionKey = restriction.key + const crossesMidnight = restriction.endTime <= restriction.startTime + return vstack(h, { gap: "2", className: "rounded-md border p-3", attributes: [h.Key(restrictionKey)] }, [ + hstack(h, { gap: "2", justify: "between", wrap: true }, [ + multiSegmentedSelect(h, { + ariaLabel: "Days", + size: "sm", + className: "flex-wrap", + value: restriction.weekdays, + onToggle: (weekday) => Message.ToggledRestrictionDay({ key, restrictionKey, weekday }), + options: Arr.map(WEEKDAYS, (day) => ({ + value: day.value, + label: day.label, + ariaLabel: day.label, + })), + }), + iconButton( + h, + { + label: "Remove this window", + className: "text-muted-foreground", + attributes: [h.OnClick(Message.ClickedRemoveRestriction({ key, restrictionKey }))], + }, + [icon(h, "TrashIcon")], + ), + ]), + hstack(h, { gap: "3", wrap: true, align: "end" }, [ + textInput(h, { + id: `${restrictionKey}-start`, + label: "From", + type: "time", + value: restriction.startTime, + className: "w-32", + onInput: (value) => + Message.ChangedRestrictionTime({ key, restrictionKey, edge: "start", value }), + }), + textInput(h, { + id: `${restrictionKey}-end`, + label: "To", + type: "time", + value: restriction.endTime, + className: "w-32", + onInput: (value) => + Message.ChangedRestrictionTime({ key, restrictionKey, edge: "end", value }), + }), + typography(h, { size: "xs", tone: "muted", className: "pb-2" }, [ + crossesMidnight ? "Ends the next morning" : "Same day", + ]), + ]), + ]) +} + +const restrictionsField = (h: H, model: Model, layer: LayerDraft): Html => { + const errors = layerErrors(layer, model.form.timeZone).restrictions + return vstack(h, { gap: "2" }, [ + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + layer.restrictions.length === 0 + ? "Always active. Limit it to weekly windows, like nights or weekends." + : "Only active in these windows", + ]), + ...Arr.flatMap(layer.restrictions, (restriction, index) => [ + restrictionRow(h, layer, restriction), + ...shown(h, model, errors[index] ?? Option.none()), + ]), + button( + h, + { + variant: "outline", + size: "sm", + className: "self-start", + isDisabled: layer.restrictions.length >= MAX_ONCALL_RESTRICTIONS, + attributes: [h.OnClick(Message.ClickedAddRestriction({ key: layer.key }))], + }, + [icon(h, "PlusIcon"), "Add window"], + ), + ]) +} + +/** One layer: its name and order, its people, how it hands off, from when, and its windows. */ +const layerBlock = (h: H, model: Model, layer: LayerDraft, index: number): Html => { + const key = layer.key + const count = model.form.layers.length + const errors = layerErrors(layer, model.form.timeZone) + return panel(h, { padded: "sm", className: "gap-4", attributes: [h.Key(key)] }, [ + hstack(h, { gap: "2", justify: "between", align: "end" }, [ + textInput(h, { + id: `${key}-name`, + label: index === 0 ? `Layer ${index + 1}, at the bottom` : `Layer ${index + 1}`, + value: layer.name, + className: "min-w-0 max-w-xs flex-1", + onInput: layerText(key, "name"), + }), + hstack(h, { gap: "0.5" }, [ + iconButton( + h, + { + label: `Move layer ${index + 1} up`, + isDisabled: index === 0, + attributes: [h.OnClick(Message.ClickedMoveLayer({ key, direction: "up" }))], + }, + [icon(h, "ArrowUpIcon")], + ), + iconButton( + h, + { + label: `Move layer ${index + 1} down`, + isDisabled: index === count - 1, + attributes: [h.OnClick(Message.ClickedMoveLayer({ key, direction: "down" }))], + }, + [icon(h, "ArrowDownIcon")], + ), + iconButton( + h, + { + label: `Remove layer ${index + 1}`, + isDisabled: count === 1, + attributes: [h.OnClick(Message.ClickedRemoveLayer({ key }))], + }, + [icon(h, "TrashIcon")], + ), + ]), + ]), + ...shown(h, model, errors.name), + participantsField(h, model, layer, index), + ...shown(h, model, errors.participants), + rotationField(h, layer), + ...shown(h, model, errors.handoff), + textInput(h, { + id: `${key}-starts`, + label: `Starts, in ${model.form.timeZone}: the first person holds this shift`, + type: "datetime-local", + value: layer.startsAt, + className: "max-w-sm", + onInput: layerText(key, "startsAt"), + }), + ...shown(h, model, errors.startsAt), + restrictionsField(h, model, layer), + ]) +} + +const layersSection = (h: H, model: Model): Html => + formSection(h, { title: "Layers", hint: "Later layers sit on top where they have someone on call" }, [ + ...Arr.map(model.form.layers, (layer, index) => layerBlock(h, model, layer, index)), + ...shown( + h, + model, + model.form.layers.length === 0 ? Option.some("Add at least one layer") : Option.none(), + ), + hstack(h, { gap: "3" }, [ + button( + h, + { + variant: "outline", + size: "sm", + isDisabled: model.form.layers.length >= MAX_ONCALL_LAYERS, + attributes: [h.OnClick(Message.ClickedAddLayer())], + }, + [icon(h, "PlusIcon"), "Add layer"], + ), + typography(h, { size: "xs", tone: "muted", numeric: true }, [ + `${model.form.layers.length} of ${MAX_ONCALL_LAYERS}`, + ]), + ]), + ]) + +const backHref = (model: Model) => + model.editingId === null ? oncallTabHref() : scheduleHref(model.editingId) + +const actionBar = (h: H, model: Model): Html => { + const issues = formIssues(model.form) + return formActionBar(h, { + issues, + widthClass: FORM_WIDTH, + notice: + model.saveFailure === null + ? null + : alert(h, { variant: "crit", size: "sm", className: `mb-3 ${FORM_WIDTH}` }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, [ + model.editingId === null + ? "Couldn't create the schedule" + : "Couldn't save the schedule", + ]), + alertDescription(h, [failureMessage(model.saveFailure)]), + ]), + actions: [ + linkButton(h, { variant: "outline", href: backHref(model) }, ["Cancel"]), + button( + h, + { + isDisabled: !canManage(model), + isLoading: model.isSaving, + attributes: [h.OnClick(Message.ClickedSave())], + }, + [model.editingId === null ? "Create schedule" : "Save changes"], + ), + ], + }) +} + +export const view = Submodel.defineView((model, _inputs, h) => { + const isEdit = model.editingId !== null + const header = { + title: isEdit ? "Edit schedule" : "New schedule", + description: "Who is on call when: people take turns in layers, in one time zone.", + } + if (model.isSessionLoaded && !canManage(model)) + return managersOnlyPage(h, { + header, + title: "Only org admins and engineers can create or change on-call schedules", + back: { href: backHref(model), label: isEdit ? "Back to the schedule" : "Back to on-call" }, + }) + if (isEdit && AsyncData.isFailure(model.existing.data)) + return dashboardPage(h, header, [ + errorState(h, { failure: model.existing.data.error, title: "Couldn't load the schedule" }), + ]) + if (!model.isReady || (isEdit && !AsyncData.isSuccess(model.existing.data))) + return dashboardPage(h, header, [skeleton(h, "h-24 w-full"), skeleton(h, "h-80 w-full")]) + return dashboardPage(h, { ...header, fill: true }, [ + formScrollArea(h, [ + formCard(h, [nameSection(h, model), zoneSection(h, model), layersSection(h, model)], FORM_WIDTH), + ]), + actionBar(h, model), + ]) +}) diff --git a/apps/web/src/page/alerts/schedule/command.ts b/apps/web/src/page/alerts/schedule/command.ts new file mode 100644 index 0000000000..a89739dbe2 --- /dev/null +++ b/apps/web/src/page/alerts/schedule/command.ts @@ -0,0 +1,84 @@ +import { OnCallOverrideId, OnCallScheduleId } from "@maple/domain/http" +import { V2OnCallOverrideCreateParams } from "@maple/domain/http/v2" +import { Clock, Effect, Schema } from "effect" +import { Command } from "foldkit" +import { readGrants } from "../../../data/session-tenant" +import { + createOverride, + deleteOverride, + listOverrides, + listShifts, + retrieveOnCallSchedule, +} from "../shared/oncall-schedules" +import { listOrgMembers } from "../shared/org-members" +import { Message } from "./message" + +export const ReadScheduleClock = Command.define("ReadScheduleClock", { + messages: [Message.ReadClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.ReadClock({ nowMs })), +}) + +export const FetchSchedule = Command.define("FetchOnCallSchedule", { + args: { key: Schema.String, scheduleId: OnCallScheduleId }, + messages: [Message.CompletedFetchSchedule], + execute: ({ key, scheduleId }) => + retrieveOnCallSchedule(scheduleId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchSchedule({ key, result })), + ), +}) + +export const FetchShifts = Command.define("FetchOnCallShifts", { + args: { key: Schema.String, scheduleId: OnCallScheduleId, fromMs: Schema.Number, toMs: Schema.Number }, + messages: [Message.CompletedFetchShifts], + execute: ({ key, scheduleId, fromMs, toMs }) => + listShifts(scheduleId, fromMs, toMs).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchShifts({ key, result })), + ), +}) + +export const FetchOverrides = Command.define("FetchOnCallOverrides", { + args: { key: Schema.String, scheduleId: OnCallScheduleId }, + messages: [Message.CompletedFetchOverrides], + execute: ({ key, scheduleId }) => + listOverrides(scheduleId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchOverrides({ key, result })), + ), +}) + +export const FetchScheduleMembers = Command.define("FetchScheduleMembers", { + messages: [Message.CompletedFetchMembers], + execute: listOrgMembers.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchMembers({ result })), + ), +}) + +export const FetchScheduleSession = Command.define("FetchScheduleSession", { + args: { orgId: Schema.String }, + messages: [Message.CompletedFetchSession], + execute: ({ orgId }) => + Effect.map(readGrants, (grants) => Message.CompletedFetchSession({ orgId, grants })), +}) + +export const CreateOverride = Command.define("CreateOnCallOverride", { + args: { openCount: Schema.Number, scheduleId: OnCallScheduleId, payload: V2OnCallOverrideCreateParams }, + messages: [Message.CompletedCreateOverride], + execute: ({ openCount, scheduleId, payload }) => + createOverride(scheduleId, payload).pipe( + Effect.result, + Effect.map((result) => Message.CompletedCreateOverride({ openCount, result })), + ), +}) + +export const DeleteOverride = Command.define("DeleteOnCallOverride", { + args: { scheduleId: OnCallScheduleId, overrideId: OnCallOverrideId }, + messages: [Message.CompletedDeleteOverride], + execute: ({ scheduleId, overrideId }) => + deleteOverride(scheduleId, overrideId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedDeleteOverride({ overrideId, result })), + ), +}) diff --git a/apps/web/src/page/alerts/schedule/index.ts b/apps/web/src/page/alerts/schedule/index.ts new file mode 100644 index 0000000000..eb7cc1d1eb --- /dev/null +++ b/apps/web/src/page/alerts/schedule/index.ts @@ -0,0 +1,39 @@ +import { Clock, Effect, Schema, Stream } from "effect" +import { Subscription } from "foldkit" +import type { Resources } from "../../../platform/resources" +import { definePage, type PageSubscriptionInput } from "../../contract" +import { Message } from "./message" +import { Model } from "./model" +import { init, update } from "./update" +import { breadcrumbsOf, view } from "./view" + +/** The now line and the range follow the clock, on the hub's 30-second cadence. */ +const CLOCK_TICK = "30 seconds" + +const subscriptions = Subscription.make, Message, Resources>()((entry) => ({ + clock: entry(Schema.Struct({}).fields, { + modelToDependencies: () => ({}), + dependenciesToStream: () => + Stream.tick(CLOCK_TICK).pipe( + Stream.drop(1), + Stream.mapEffect(() => + Effect.map(Clock.currentTimeMillis, (nowMs) => Message.ReadClock({ nowMs })), + ), + ), + }), +})) + +/** `/alerts/schedules/$scheduleId`: one instance per schedule. */ +export const page = definePage( + "AlertsSchedule", + { Model, Message }, + { + routes: ["AlertsSchedule"], + key: (route) => route.scheduleId, + init, + update, + view, + subscriptions, + breadcrumbs: breadcrumbsOf, + }, +) diff --git a/apps/web/src/page/alerts/schedule/message.ts b/apps/web/src/page/alerts/schedule/message.ts new file mode 100644 index 0000000000..a7eff5dc4c --- /dev/null +++ b/apps/web/src/page/alerts/schedule/message.ts @@ -0,0 +1,35 @@ +import * as Policy from "@maple/domain/policy" +import { OnCallOverrideId } from "@maple/domain/http" +import { V2OnCallOverride, V2OnCallSchedule, V2OnCallShifts } from "@maple/domain/http/v2" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Dialog from "@maple/views/ui/dialog" +import { OrgMember } from "../shared/org-members" + +const outcome = (success: S) => Schema.Result(success, LoadFailure) + +export const Message = defineMessageUnion({ + ReadClock: { nowMs: Schema.Number }, + CompletedFetchSchedule: { key: Schema.String, result: outcome(V2OnCallSchedule) }, + CompletedFetchShifts: { key: Schema.String, result: outcome(V2OnCallShifts) }, + CompletedFetchOverrides: { key: Schema.String, result: outcome(Schema.Array(V2OnCallOverride)) }, + CompletedFetchMembers: { result: Schema.Result(Schema.Array(OrgMember), Schema.String) }, + CompletedFetchSession: { orgId: Schema.String, grants: Schema.Array(Policy.PermissionName) }, + ClickedRetry: {}, + ClickedPreviousWeek: {}, + ClickedNextWeek: {}, + ClickedThisWeek: {}, + /** A final shift's bar: an override for its range, its person preselected. */ + ClickedShift: { startMs: Schema.Number, endMs: Schema.Number }, + ClickedNewOverride: {}, + GotOverrideDialogMessage: { message: Dialog.Message }, + ChangedOverrideUser: { value: Schema.String }, + ChangedOverrideStart: { value: Schema.String }, + ChangedOverrideEnd: { value: Schema.String }, + ClickedCreateOverride: {}, + CompletedCreateOverride: { openCount: Schema.Number, result: outcome(V2OnCallOverride) }, + ClickedDeleteOverride: { overrideId: OnCallOverrideId }, + CompletedDeleteOverride: { overrideId: OnCallOverrideId, result: outcome(Schema.Void) }, +}) +export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/schedule/model.ts b/apps/web/src/page/alerts/schedule/model.ts new file mode 100644 index 0000000000..fd46ad1b59 --- /dev/null +++ b/apps/web/src/page/alerts/schedule/model.ts @@ -0,0 +1,44 @@ +import * as Policy from "@maple/domain/policy" +import { OnCallOverrideId, OnCallScheduleId } from "@maple/domain/http" +import { V2OnCallOverride, V2OnCallSchedule, V2OnCallShifts } from "@maple/domain/http/v2" +import { Schema } from "effect" +import { KeyedSchema } from "@maple/views/data/keyed" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Dialog from "@maple/views/ui/dialog" +import { OrgMember } from "../shared/org-members" + +// `/alerts/schedules/$scheduleId`: who is on call, the calendar of shifts, and the overrides. + +/** The override form as typed: who, and from when to when on the schedule zone's wall clock. */ +export const OverrideDraft = Schema.Struct({ + userId: Schema.String, + startsAt: Schema.String, + endsAt: Schema.String, +}) +export type OverrideDraft = typeof OverrideDraft.Type + +export const Model = Schema.Struct({ + rawScheduleId: Schema.String, + scheduleId: Schema.NullOr(OnCallScheduleId), + /** The clock, read on entry and by the hub cadence's tick. */ + nowMs: Schema.NullOr(Schema.Number), + schedule: KeyedSchema(V2OnCallSchedule, LoadFailure), + /** Weeks from this one the calendar shows: 0 is the week starting today. */ + weekOffset: Schema.Number, + shifts: KeyedSchema(V2OnCallShifts, LoadFailure), + overrides: KeyedSchema(Schema.Array(V2OnCallOverride), LoadFailure), + members: Schema.NullOr(Schema.Array(OrgMember)), + grants: Schema.Array(Policy.PermissionName), + overrideDialog: Dialog.Model, + overrideDraft: OverrideDraft, + isShowingOverrideErrors: Schema.Boolean, + isCreatingOverride: Schema.Boolean, + overrideError: Schema.NullOr(Schema.String), + /** Bumped on every open: a create's result only settles the dialog it was sent from. */ + overrideOpenCount: Schema.Number, + deletingOverrideId: Schema.NullOr(OnCallOverrideId), +}) +export type Model = typeof Model.Type + +/** The viewer may change schedules and overrides (`alerts:manage`). */ +export const canManage = (model: Pick): boolean => Policy.has(model.grants, "alerts:manage") diff --git a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts new file mode 100644 index 0000000000..9335ededb9 --- /dev/null +++ b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts @@ -0,0 +1,159 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { OnCallOverrideId } from "@maple/domain/http" +import { zonedDayStart } from "@maple/views/ui/shift-timeline" +import { Option, Result, Schema, Array as Arr } from "effect" +import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import type { PageContext } from "../../contract" +import { + checkoutOverrides, + checkoutSchedule, + checkoutShifts, + DST_NOW, + memberId, + ONCALL_NOW, + oncallMembers, + overrideUuid, + schedulePublicId, +} from "../shared/oncall-test-fixtures" +import { ORG } from "../shared/test-rows" +import { Message } from "./message" +import type { Model } from "./model" +import { CALENDAR_DAYS, init, overrideIssue, 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 names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) +const run = (model: Model, messages: ReadonlyArray) => + Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) + +const ENGINEER = Policy.grantsOf(["org:engineer"]) +const MEMBER = Policy.grantsOf(["org:member"]) +const BERLIN = "Europe/Berlin" + +const loaded = (nowMs: number, grants = ENGINEER) => { + const entered = init({ _tag: "AlertsSchedule", scheduleId: schedulePublicId(1), search: {} }, context) + expect(names(entered.commands)).toEqual( + expect.arrayContaining(["FetchOnCallSchedule", "FetchOnCallOverrides", "FetchScheduleMembers"]), + ) + const base = run(entered.model, [ + Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + Message.CompletedFetchSession({ orgId: ORG, grants }), + Message.ReadClock({ nowMs }), + Message.CompletedFetchSchedule({ + key: entered.model.schedule.key ?? "", + result: Result.succeed(checkoutSchedule(nowMs)), + }), + Message.CompletedFetchOverrides({ + key: entered.model.overrides.key ?? "", + result: Result.succeed(checkoutOverrides(nowMs)), + }), + ]) + const fromMs = zonedDayStart(nowMs, BERLIN) + const toMs = zonedDayStart(nowMs, BERLIN, CALENDAR_DAYS) + return run(base, [ + Message.CompletedFetchShifts({ + key: base.shifts.key ?? "", + result: Result.succeed(checkoutShifts(nowMs, fromMs, toMs)), + }), + ]) +} + +describe("on-call schedule page", () => { + test("the week reads each layer and the final row in the schedule's zone, noting the viewer's", () => { + scene( + { update: pageUpdate, view }, + given(loaded(ONCALL_NOW)), + expectView(text("Checkout primary")).toExist(), + expectView( + text( + "Times in Europe/Berlin. Yours is UTC. Pick a shift in the final row to cover it with an override.", + ), + ).toExist(), + expectView(role("rowheader", { name: /Final schedule/u })).toExist(), + expectView(role("rowheader", { name: /Weeknights/u })).toExist(), + expectView(role("button", { name: /^Override: Ana Souza, Wed,? 11 Nov/u })).toExist(), + expectView(text("Upcoming overrides")).toExist(), + expectView(role("button", { name: "Delete Dana Kim's override" })).toExist(), + ) + }) + + test("the DST week runs Thursday to Wednesday on the Berlin clock, through the fall-back Sunday", () => { + const model = loaded(DST_NOW) + scene( + { update: pageUpdate, view }, + given(model), + expectView(text("Sun 25")).toExist(), + expectView(text("22 Oct to 28 Oct")).toExist(), + ) + }) + + test("moving a week reads that week's shifts; Today comes back", () => { + const model = loaded(ONCALL_NOW) + const next = pageUpdate(model, Message.ClickedNextWeek()) + expect(names(next.commands)).toEqual(["FetchOnCallShifts"]) + expect(next.model.weekOffset).toBe(1) + expect(pageUpdate(next.model, Message.ClickedThisWeek()).model.weekOffset).toBe(0) + }) + + test("a final shift opens an override for its range; creating one sends the times in UTC", () => { + const fromMs = zonedDayStart(ONCALL_NOW, BERLIN, 2) + const opened = run(loaded(ONCALL_NOW), [ + Message.ClickedShift({ startMs: fromMs, endMs: fromMs + 8 * 3_600_000 }), + Message.ChangedOverrideUser({ value: memberId(7) }), + ]) + expect(opened.overrideDraft.startsAt).toBe("2026-11-12T00:00") + const created = pageUpdate(opened, Message.ClickedCreateOverride()) + expect(names(created.commands)).toEqual(["CreateOnCallOverride"]) + }) + + test("an override has to end after it starts and in the future", () => { + const draft = { userId: memberId(1), startsAt: "2026-11-12T10:00", endsAt: "2026-11-12T09:00" } + expect(overrideIssue(draft, BERLIN, ONCALL_NOW)).toEqual( + Option.some("End the override after it starts"), + ) + expect( + overrideIssue( + { ...draft, startsAt: "2026-11-01T08:00", endsAt: "2026-11-02T08:00" }, + BERLIN, + ONCALL_NOW, + ), + ).toEqual(Option.some("End the override in the future")) + expect(overrideIssue({ ...draft, userId: "" }, BERLIN, ONCALL_NOW)).toEqual( + Option.some("Pick who covers the shift"), + ) + expect(overrideIssue({ ...draft, endsAt: "2026-11-12T18:00" }, BERLIN, ONCALL_NOW)).toEqual( + Option.none(), + ) + }) + + test("deleting an override sends one delete, then reads the overrides and shifts again", () => { + const overrideId = Schema.decodeSync(OnCallOverrideId)(overrideUuid(1)) + const asked = pageUpdate(loaded(ONCALL_NOW), Message.ClickedDeleteOverride({ overrideId })) + expect(names(asked.commands)).toEqual(["DeleteOnCallOverride"]) + expect( + names(pageUpdate(asked.model, Message.ClickedDeleteOverride({ overrideId })).commands), + ).toEqual([]) + const done = pageUpdate( + asked.model, + Message.CompletedDeleteOverride({ overrideId, result: Result.void }), + ) + expect(names(done.commands)).toContain("FetchOnCallOverrides") + expect(names(done.commands)).toContain("FetchOnCallShifts") + }) + + test("a member reads the calendar but cannot add or delete an override", () => { + scene( + { update: pageUpdate, view }, + given(loaded(ONCALL_NOW, MEMBER)), + expectView(role("button", { name: "Add override" })).not.toExist(), + expectView(role("button", { name: "Delete Dana Kim's override" })).not.toExist(), + expectView(text("Times in Europe/Berlin. Yours is UTC.")).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/schedule/update.ts b/apps/web/src/page/alerts/schedule/update.ts new file mode 100644 index 0000000000..53ababa8d1 --- /dev/null +++ b/apps/web/src/page/alerts/schedule/update.ts @@ -0,0 +1,322 @@ +import { MAX_ONCALL_OVERRIDE_MS, UserId } from "@maple/domain/http" +import type { V2OnCallOverrideCreateParams, V2OnCallSchedule } from "@maple/domain/http/v2" +import { Option, Result, Schema } from "effect" +import { AsyncData, Command as FoldkitCommand } from "foldkit" +import * as Keyed from "@maple/views/data/keyed" +import { requestKey } from "@maple/views/data/request-key" +import * as Dialog from "@maple/views/ui/dialog" +import { zonedDayStart } from "@maple/views/ui/shift-timeline" +import type { AppRoute } from "../../../route" +import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" +import { scheduleIdFromUrl } from "../shared/oncall-schedules" +import { localInputMs, localInputOf } from "../schedule-form/form" +import { + CreateOverride, + DeleteOverride, + FetchOverrides, + FetchSchedule, + FetchScheduleMembers, + FetchScheduleSession, + FetchShifts, + ReadScheduleClock, +} from "./command" +import { Message } from "./message" +import { canManage, type Model, type OverrideDraft } from "./model" +import { msIso } from "../shared/time" + +type Return = PageReturn +type ScheduleRoute = Extract + +export const OVERRIDE_DIALOG_ID = "schedule-override" + +/** The calendar shows one week of the schedule zone's days, from today. */ +export const CALENDAR_DAYS = 7 + +export const scheduleOf = (model: Model): Option.Option => + AsyncData.getData(model.schedule.data) + +/** The calendar's range: whole days on the schedule's own clock. */ +export const rangeOf = (model: Model): Option.Option> => + Option.flatMap(scheduleOf(model), (schedule) => + model.nowMs === null + ? Option.none() + : Option.some({ + fromMs: zonedDayStart(model.nowMs, schedule.time_zone, model.weekOffset * CALENDAR_DAYS), + toMs: zonedDayStart( + model.nowMs, + schedule.time_zone, + (model.weekOffset + 1) * CALENDAR_DAYS, + ), + }), + ) + +export const init = (route: ScheduleRoute, context: PageContext): Return => { + const scheduleId = Option.getOrNull(scheduleIdFromUrl(route.scheduleId)) + const model: Model = { + rawScheduleId: route.scheduleId, + scheduleId, + nowMs: null, + schedule: Keyed.idle(), + weekOffset: 0, + shifts: Keyed.idle(), + overrides: Keyed.idle(), + members: null, + grants: [], + overrideDialog: Dialog.init(OVERRIDE_DIALOG_ID, "dialog"), + overrideDraft: { userId: "", startsAt: "", endsAt: "" }, + isShowingOverrideErrors: false, + isCreatingOverride: false, + overrideError: null, + overrideOpenCount: 0, + deletingOverrideId: null, + } + const common = [ + ReadScheduleClock(), + FetchScheduleMembers(), + FetchScheduleSession({ orgId: context.orgId }), + ] + if (scheduleId === null) return { model, commands: common } + const schedule = Keyed.supersede( + model.schedule, + requestKey(context.orgId, "oncallSchedules.retrieve", { id: scheduleId }), + ) + const overrides = Keyed.supersede( + model.overrides, + requestKey(context.orgId, "oncallSchedules.listOverrides", { id: scheduleId }), + ) + return { + model: { ...model, schedule, overrides }, + commands: [ + ...common, + FetchSchedule({ key: schedule.key, scheduleId }), + FetchOverrides({ key: overrides.key, scheduleId }), + ], + } +} + +/** The shifts of the range on screen; a new range or a write reads them again. */ +const requestShifts = (model: Model, context: PageContext, mode: Keyed.ReadMode): Return => { + const scheduleId = model.scheduleId + return Option.match(rangeOf(model), { + onNone: () => ({ model }), + onSome: ({ fromMs, toMs }) => { + if (scheduleId === null) return { model } + const key = requestKey(context.orgId, "oncallSchedules.shifts", { id: scheduleId, fromMs, toMs }) + return Option.match(Keyed.requestFor(model.shifts, key, mode), { + onNone: () => ({ model }), + onSome: (shifts) => ({ + model: { ...model, shifts }, + commands: [FetchShifts({ key: shifts.key, scheduleId, fromMs, toMs })], + }), + }) + }, + }) +} + +const refreshOverrides = (model: Model, context: PageContext): Return => { + const scheduleId = model.scheduleId + if (scheduleId === null) return { model } + const key = requestKey(context.orgId, "oncallSchedules.listOverrides", { id: scheduleId }) + const overrides = Keyed.supersede(model.overrides, key) + const shifts = requestShifts({ ...model, overrides }, context, "Supersede") + return { + model: shifts.model, + commands: [FetchOverrides({ key: overrides.key, scheduleId }), ...(shifts.commands ?? [])], + } +} + +const decodeUserId = Schema.decodeUnknownOption(UserId) + +/** What is wrong with the override form, or none. */ +export const overrideIssue = ( + draft: OverrideDraft, + timeZone: string, + nowMs: number, +): Option.Option => { + const start = localInputMs(draft.startsAt, timeZone) + const end = localInputMs(draft.endsAt, timeZone) + if (Option.isNone(decodeUserId(draft.userId))) return Option.some("Pick who covers the shift") + if (Option.isNone(start) || Option.isNone(end)) + return Option.some("Pick when the override starts and ends") + if (end.value <= start.value) return Option.some("End the override after it starts") + if (end.value <= nowMs) return Option.some("End the override in the future") + if (end.value - start.value > MAX_ONCALL_OVERRIDE_MS) return Option.some("Keep an override to 90 days") + return Option.none() +} + +const payloadOf = ( + draft: OverrideDraft, + timeZone: string, + nowMs: number, +): Option.Option => + Option.isSome(overrideIssue(draft, timeZone, nowMs)) + ? Option.none() + : Option.flatMap(decodeUserId(draft.userId), (user_id) => + Option.flatMap( + Option.all([ + localInputMs(draft.startsAt, timeZone), + localInputMs(draft.endsAt, timeZone), + ]), + ([start, end]) => + Option.some({ + user_id, + starts_at: msIso(start), + ends_at: msIso(end), + }), + ), + ) + +const liftDialog = (model: Model, step: ReturnType): Return => ({ + model: { ...model, overrideDialog: step.model }, + commands: FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotOverrideDialogMessage({ message }), + ), +}) + +/** Opens the override dialog over a range: a clicked shift's, or the next hour's. */ +const openOverride = (model: Model, startMs: number, endMs: number): Return => + Option.match(scheduleOf(model), { + onNone: () => ({ model }), + onSome: (schedule) => + liftDialog( + { + ...model, + overrideDraft: { + userId: "", + // An override cannot lie wholly in the past: one over a running shift starts now. + startsAt: localInputOf(Math.max(startMs, model.nowMs ?? startMs), schedule.time_zone), + endsAt: localInputOf(endMs, schedule.time_zone), + }, + isShowingOverrideErrors: false, + overrideError: null, + overrideOpenCount: model.overrideOpenCount + 1, + }, + Dialog.open(model.overrideDialog), + ), + }) + +const HOUR_MS = 3_600_000 + +const createOverride = (model: Model): Return => { + const scheduleId = model.scheduleId + return Option.match(scheduleOf(model), { + onNone: () => ({ model }), + onSome: (schedule) => + model.isCreatingOverride || scheduleId === null + ? { model } + : Option.match(payloadOf(model.overrideDraft, schedule.time_zone, model.nowMs ?? 0), { + onNone: () => ({ model: { ...model, isShowingOverrideErrors: true } }), + onSome: (payload) => ({ + model: { ...model, isCreatingOverride: true, overrideError: null }, + commands: [ + CreateOverride({ openCount: model.overrideOpenCount, scheduleId, payload }), + ], + }), + }), + }) +} + +const toast = (type: "success" | "error", title: string, description = "") => + PageOutMessage.RequestedToast({ type, title, description }) + +const moveWeek = (model: Model, context: PageContext, weekOffset: number): Return => + requestShifts({ ...model, weekOffset }, context, "Load") + +export const update = (model: Model, message: Message, context: PageContext): Return => + Message.match(message, { + ReadClock: ({ nowMs }) => requestShifts({ ...model, nowMs }, context, "Load"), + CompletedFetchSchedule: ({ key, result }) => + requestShifts({ ...model, schedule: Keyed.accept(model.schedule, key, result) }, context, "Load"), + CompletedFetchShifts: ({ key, result }) => ({ + model: { ...model, shifts: Keyed.accept(model.shifts, key, result) }, + }), + CompletedFetchOverrides: ({ key, result }) => ({ + model: { ...model, overrides: Keyed.accept(model.overrides, key, result) }, + }), + CompletedFetchMembers: ({ result }) => + Result.match(result, { + onSuccess: (members) => ({ model: { ...model, members } }), + onFailure: () => ({ model: { ...model, members: [] } }), + }), + CompletedFetchSession: ({ orgId, grants }) => + orgId === context.orgId ? { model: { ...model, grants } } : { model }, + ClickedRetry: () => { + const scheduleId = model.scheduleId + if (scheduleId === null) return { model } + const schedule = Keyed.supersede( + model.schedule, + requestKey(context.orgId, "oncallSchedules.retrieve", { id: scheduleId }), + ) + return { + model: { ...model, schedule }, + commands: [FetchSchedule({ key: schedule.key, scheduleId })], + } + }, + ClickedPreviousWeek: () => moveWeek(model, context, model.weekOffset - 1), + ClickedNextWeek: () => moveWeek(model, context, model.weekOffset + 1), + ClickedThisWeek: () => moveWeek(model, context, 0), + ClickedShift: ({ startMs, endMs }) => + canManage(model) ? openOverride(model, startMs, endMs) : { model }, + ClickedNewOverride: () => { + const now = model.nowMs ?? 0 + const start = Math.ceil(now / HOUR_MS) * HOUR_MS + return openOverride(model, start, start + 8 * HOUR_MS) + }, + GotOverrideDialogMessage: ({ message: child }) => + model.isCreatingOverride + ? { model } + : liftDialog(model, Dialog.update(model.overrideDialog, child)), + ChangedOverrideUser: ({ value }) => ({ + model: { ...model, overrideDraft: { ...model.overrideDraft, userId: value } }, + }), + ChangedOverrideStart: ({ value }) => ({ + model: { ...model, overrideDraft: { ...model.overrideDraft, startsAt: value } }, + }), + ChangedOverrideEnd: ({ value }) => ({ + model: { ...model, overrideDraft: { ...model.overrideDraft, endsAt: value } }, + }), + ClickedCreateOverride: () => createOverride(model), + CompletedCreateOverride: ({ openCount, result }) => + openCount !== model.overrideOpenCount + ? { model } + : Result.match(result, { + onSuccess: (): Return => { + const closed = liftDialog( + { ...model, isCreatingOverride: false }, + Dialog.close(model.overrideDialog), + ) + const refreshed = refreshOverrides(closed.model, context) + return { + model: refreshed.model, + commands: [...(closed.commands ?? []), ...(refreshed.commands ?? [])], + outMessage: toast("success", "Override created"), + } + }, + onFailure: (failure): Return => ({ + model: { ...model, isCreatingOverride: false, overrideError: failure.message }, + }), + }), + ClickedDeleteOverride: ({ overrideId }) => + model.deletingOverrideId !== null || model.scheduleId === null + ? { model } + : { + model: { ...model, deletingOverrideId: overrideId }, + commands: [DeleteOverride({ scheduleId: model.scheduleId, overrideId })], + }, + CompletedDeleteOverride: ({ result }) => { + const settled = { ...model, deletingOverrideId: null } + return Result.match(result, { + onSuccess: (): Return => ({ + ...refreshOverrides(settled, context), + outMessage: toast("success", "Override deleted"), + }), + onFailure: (failure): Return => ({ + model: settled, + outMessage: toast("error", "Couldn't delete the override", failure.message), + }), + }) + }, + }) + +export const overridesOf = (model: Model) => + Option.getOrElse(AsyncData.getData(model.overrides.data), () => []) diff --git a/apps/web/src/page/alerts/schedule/view.ts b/apps/web/src/page/alerts/schedule/view.ts new file mode 100644 index 0000000000..8898cd56dc --- /dev/null +++ b/apps/web/src/page/alerts/schedule/view.ts @@ -0,0 +1,421 @@ +import type { V2OnCallOverride, V2OnCallSchedule, V2OnCallShift, V2OnCallShifts } from "@maple/domain/http/v2" +import { Option, Array as Arr } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" +import { borderedBox } from "@maple/views/ui/bordered-box" +import { button, linkButton } from "@maple/views/ui/button" +import { errorState } from "@maple/views/ui/error-state" +import { fieldErrorText } from "@maple/views/ui/field" +import { formDialog } from "@maple/views/ui/form-dialog" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" +import { input } from "@maple/views/ui/input" +import { label } from "@maple/views/ui/label" +import { nativeOption, nativeSelect } from "@maple/views/ui/native-select" +import { personLabel } from "@maple/views/ui/person-avatar" +import { shiftTimeline, type TimelineBar, type TimelineRow } from "@maple/views/ui/shift-timeline" +import { skeleton } from "@maple/views/ui/skeleton" +import { hstack, vstack } from "@maple/views/ui/stack" +import { typography, typographyClass } from "@maple/views/ui/typography" +import type { PageViewInputs } from "../../contract" +import { dashboardPage } from "../../kit/dashboard-page" +import { detailActions, detailTitle, detailTitleSkeleton } from "../../kit/detail-header" +import { resourceNotFound } from "../../kit/resource-not-found" +import { sectionHeading } from "../../kit/section-heading" +import { editScheduleHref, formatShiftEdge, oncallTabHref } from "../shared/oncall-schedules" +import { memberName, memberOf, type OrgMember } from "../shared/org-members" +import { Message } from "./message" +import { canManage, type Model } from "./model" +import { OVERRIDE_DIALOG_ID, overrideIssue, overridesOf, rangeOf, scheduleOf } from "./update" +import { isoMs } from "../shared/time" + +// `/alerts/schedules/$scheduleId`: who is on call now, the week's calendar (each layer and the +// final row with its overrides), the upcoming overrides, and the dialog that adds one. + +type H = HtmlBuilder + +const imageOf = (members: ReadonlyArray | null, userId: string) => + Option.getOrUndefined(Option.map(memberOf(members, userId), (member) => member.imageUrl)) + +const rangeText = (startMs: number, endMs: number, timeZone: string) => + `${formatShiftEdge(startMs, timeZone)} to ${formatShiftEdge(endMs, timeZone)}` + +const onCallLine = (model: Model, schedule: V2OnCallSchedule): string => + Option.match(Option.fromNullishOr(schedule.on_call_now), { + onNone: () => "Nobody is on call right now", + onSome: (now) => + `${memberName(model.members, now.user_id)} is on call until ${formatShiftEdge(isoMs(now.ends_at), schedule.time_zone)}`, + }) + +/** A shift as a bar: the person's name, and who and when in its tooltip. */ +const barOf = ( + model: Model, + schedule: V2OnCallSchedule, + shift: V2OnCallShift, + key: string, + isFinal: boolean, +): TimelineBar => { + const startMs = isoMs(shift.starts_at) + const endMs = isoMs(shift.ends_at) + const name = memberName(model.members, shift.user_id) + const isOverride = shift.source.kind === "override" + const bar: TimelineBar = { + key, + startMs, + endMs, + label: name, + title: `${isOverride ? "Override: " : ""}${name}, ${rangeText(startMs, endMs, schedule.time_zone)}`, + tone: isOverride ? "info" : isFinal ? "primary" : "neutral", + } + // Only a final shift opens an override, and only for someone who may add one. + return isFinal && canManage(model) ? { ...bar, onClick: Message.ClickedShift({ startMs, endMs }) } : bar +} + +/** The final row on top, then the layers from the top one down, as they stack. */ +const timelineRows = ( + model: Model, + schedule: V2OnCallSchedule, + shifts: V2OnCallShifts, +): ReadonlyArray> => [ + { + key: "final", + label: "Final schedule", + detail: "Who gets paged", + bars: Arr.map(shifts.final, (shift, index) => barOf(model, schedule, shift, `final-${index}`, true)), + emptyText: "Nobody on call this week", + }, + ...Arr.reverse( + Arr.map(schedule.layers, (layer, index): TimelineRow => ({ + key: `layer-${index}`, + label: layer.name, + detail: `Layer ${index + 1}`, + bars: Arr.map(shifts.layers[index] ?? [], (shift, at) => + barOf(model, schedule, shift, `layer-${index}-${at}`, false), + ), + emptyText: "Not active this week", + })), + ), +] + +/** "10 Nov to 16 Nov": the first and last day the calendar shows. */ +const weekLabel = (fromMs: number, toMs: number, timeZone: string): string => { + const day = new Intl.DateTimeFormat("en-GB", { timeZone, day: "numeric", month: "short" }) + return `${day.format(fromMs)} to ${day.format(toMs - 1)}` +} + +/** The calendar's header: the week, prev/this week/next, and which zone the times read in. */ +const calendarSection = (h: H, model: Model, schedule: V2OnCallSchedule, viewerZone: string): Html => { + const range = rangeOf(model) + const nowMs = model.nowMs ?? 0 + const timeline = (fromMs: number, toMs: number, shifts: V2OnCallShifts): Html => + shiftTimeline(h, { + fromMs, + toMs, + timeZone: schedule.time_zone, + nowMs, + rows: timelineRows(model, schedule, shifts), + ariaLabel: `Shifts for ${schedule.name}`, + legend: [ + { tone: "primary", label: "On call" }, + { tone: "info", label: "Override" }, + { tone: "neutral", label: "Layer shift" }, + ], + }) + const body = Option.match(range, { + onNone: () => skeleton(h, "h-40 w-full"), + onSome: ({ fromMs, toMs }) => + AsyncData.match(model.shifts.data, { + onIdle: () => skeleton(h, "h-40 w-full"), + onLoading: () => skeleton(h, "h-40 w-full"), + onFailure: (failure) => errorState(h, { failure, title: "Couldn't load the shifts" }), + onRefreshing: (shifts) => timeline(fromMs, toMs, shifts), + onStale: ({ data }) => timeline(fromMs, toMs, data), + onSuccess: (shifts) => timeline(fromMs, toMs, shifts), + }), + }) + const zoneNote = + viewerZone === schedule.time_zone + ? `Times in ${schedule.time_zone}.` + : `Times in ${schedule.time_zone}. Yours is ${viewerZone}.` + return h.section( + [h.Class("space-y-3")], + [ + sectionHeading(h, { + title: Option.match(range, { + onNone: () => "This week", + onSome: ({ fromMs, toMs }) => weekLabel(fromMs, toMs, schedule.time_zone), + }), + actions: hstack(h, { gap: "1" }, [ + iconButton( + h, + { label: "Previous week", attributes: [h.OnClick(Message.ClickedPreviousWeek())] }, + [icon(h, "ChevronLeftIcon")], + ), + button( + h, + { + size: "sm", + variant: "outline", + isDisabled: model.weekOffset === 0, + attributes: [h.OnClick(Message.ClickedThisWeek())], + }, + ["Today"], + ), + iconButton( + h, + { label: "Next week", attributes: [h.OnClick(Message.ClickedNextWeek())] }, + [icon(h, "ChevronRightIcon")], + ), + ]), + }), + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + zoneNote, + ...(canManage(model) ? [" Pick a shift in the final row to cover it with an override."] : []), + ]), + body, + ], + ) +} + +const overrideRow = (h: H, model: Model, schedule: V2OnCallSchedule, override: V2OnCallOverride): Html => { + const name = memberName(model.members, override.user_id) + return h.li( + [h.Key(override.id), h.Class("px-4 py-3")], + [ + hstack(h, { gap: "3", justify: "between" }, [ + personLabel(h, { + name, + imageUrl: imageOf(model.members, override.user_id), + detail: rangeText(isoMs(override.starts_at), isoMs(override.ends_at), schedule.time_zone), + }), + ...(canManage(model) + ? [ + iconButton( + h, + { + label: `Delete ${name}'s override`, + isDisabled: model.deletingOverrideId === override.id, + className: "text-muted-foreground", + attributes: [ + h.OnClick(Message.ClickedDeleteOverride({ overrideId: override.id })), + ], + }, + [icon(h, "TrashIcon")], + ), + ] + : []), + ]), + ], + ) +} + +const overridesSection = (h: H, model: Model, schedule: V2OnCallSchedule): Html => { + const overrides = overridesOf(model) + return h.section( + [h.Class("space-y-3")], + [ + sectionHeading(h, { title: "Upcoming overrides" }), + AsyncData.isFailure(model.overrides.data) + ? errorState(h, { failure: model.overrides.data.error, title: "Couldn't load the overrides" }) + : overrides.length === 0 + ? typography(h, { size: "sm", tone: "muted", as: "p" }, [ + AsyncData.isLoading(model.overrides.data) + ? "Loading overrides…" + : "No overrides. One puts a person on call for a time range, over every layer.", + ]) + : borderedBox(h, { overflow: "hidden" }, [ + h.ul( + [h.Class("divide-y")], + Arr.map(overrides, (override) => overrideRow(h, model, schedule, override)), + ), + ]), + ], + ) +} + +const dialogField = (h: H, id: string, text: string, control: Html): Html => + vstack(h, { gap: "1.5" }, [ + label(h, { className: typographyClass({ size: "xs", tone: "muted" }), attributes: [h.For(id)] }, [ + text, + ]), + control, + ]) + +/** "Add override": who covers, from when to when on the schedule's clock. */ +const overrideDialog = (h: H, model: Model, schedule: V2OnCallSchedule): Html => { + const issue = overrideIssue(model.overrideDraft, schedule.time_zone, model.nowMs ?? 0) + const id = OVERRIDE_DIALOG_ID + return formDialog(h, { + slotId: `${id}-dialog`, + model: model.overrideDialog, + toParentMessage: (message) => Message.GotOverrideDialogMessage({ message }), + title: "Add override", + description: `Put one person on call for a time range, over every layer. Times in ${schedule.time_zone}.`, + panelClassName: "space-y-4", + fields: [ + dialogField( + h, + `${id}-person`, + "Who covers", + nativeSelect( + h, + { + defaultValue: model.overrideDraft.userId, + className: "w-full", + attributes: [ + h.Key(`${id}-person-${model.overrideOpenCount}-${(model.members ?? []).length}`), + h.Id(`${id}-person`), + h.OnChange((value) => Message.ChangedOverrideUser({ value })), + ], + }, + [ + nativeOption("", "Pick a person"), + ...Arr.map(model.members ?? [], (member) => nativeOption(member.userId, member.name)), + ], + ), + ), + hstack(h, { gap: "3", wrap: true }, [ + dialogField( + h, + `${id}-start`, + "From", + input(h, { + id: `${id}-start`, + type: "datetime-local", + attributes: [ + h.Value(model.overrideDraft.startsAt), + h.OnInput((value) => Message.ChangedOverrideStart({ value })), + ], + }), + ), + dialogField( + h, + `${id}-end`, + "To", + input(h, { + id: `${id}-end`, + type: "datetime-local", + attributes: [ + h.Value(model.overrideDraft.endsAt), + h.OnInput((value) => Message.ChangedOverrideEnd({ value })), + ], + }), + ), + ]), + ...(model.isShowingOverrideErrors + ? Option.match(issue, { + onNone: () => [], + onSome: (text) => [fieldErrorText(h, { attributes: [h.Role("alert")] }, text)], + }) + : []), + ...(model.overrideError === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't create the override"]), + alertDescription(h, [model.overrideError]), + ]), + ]), + ], + onSubmit: Message.ClickedCreateOverride(), + submitLabel: "Add override", + isPending: model.isCreatingOverride, + }) +} + +const page = (h: H, model: Model, viewerZone: string): Html => { + const notFound = dashboardPage(h, {}, [ + resourceNotFound(h, { + title: "Schedule not found", + description: "It may have been deleted, or the link is stale.", + back: { href: oncallTabHref(), label: "Back to on-call" }, + }), + ]) + if (model.scheduleId === null) return notFound + const data = model.schedule.data + if (AsyncData.isFailure(data)) + return data.error.code === "oncall_schedule_not_found" + ? notFound + : dashboardPage(h, {}, [ + errorState(h, { + failure: data.error, + title: "Couldn't load the schedule", + onRetry: Message.ClickedRetry(), + }), + ]) + return Option.match(scheduleOf(model), { + onNone: () => + dashboardPage(h, { titleContent: detailTitleSkeleton(h) }, [ + skeleton(h, "h-48 w-full"), + skeleton(h, "h-24 w-full"), + ]), + onSome: (schedule) => + h.div( + [h.Class("contents")], + [ + dashboardPage( + h, + { + titleContent: detailTitle(h, { + kind: ["On-call schedule", schedule.time_zone], + title: [schedule.name], + meta: [ + Option.match(Option.fromNullishOr(schedule.on_call_now), { + onNone: () => + typography(h, { size: "sm", tone: "muted" }, [ + onCallLine(model, schedule), + ]), + onSome: (now) => + personLabel(h, { + name: memberName(model.members, now.user_id), + imageUrl: imageOf(model.members, now.user_id), + detail: `On call until ${formatShiftEdge(isoMs(now.ends_at), schedule.time_zone)}`, + }), + }), + ], + }), + actions: canManage(model) + ? detailActions(h, [ + linkButton( + h, + { + size: "sm", + variant: "outline", + href: editScheduleHref(schedule.id), + }, + [icon(h, "PencilIcon"), "Edit schedule"], + ), + button( + h, + { + size: "sm", + attributes: [h.OnClick(Message.ClickedNewOverride())], + }, + [icon(h, "PlusIcon"), "Add override"], + ), + ]) + : undefined, + gap: "lg", + }, + [ + calendarSection(h, model, schedule, viewerZone), + overridesSection(h, model, schedule), + ], + ), + overrideDialog(h, model, schedule), + ], + ), + }) +} + +export const view = Submodel.defineView((model, inputs, h) => + page(h, model, inputs.context.timeZone), +) + +/** The H1 names the schedule, so the trail stops at the tab. */ +export const breadcrumbsOf = () => [ + { label: "Alerts", href: "/alerts" }, + { label: "On-call", href: oncallTabHref() }, +] diff --git a/apps/web/src/page/alerts/shared/escalation-chains.test.ts b/apps/web/src/page/alerts/shared/escalation-chains.test.ts index cbaaba121e..7b16d8c3bb 100644 --- a/apps/web/src/page/alerts/shared/escalation-chains.test.ts +++ b/apps/web/src/page/alerts/shared/escalation-chains.test.ts @@ -2,6 +2,7 @@ import { Array as Arr, Option } from "effect" import { describe, expect, it } from "vitest" import { chainSummary, formatWait, runView } from "./escalation-chains" import { chainDestinations, chainOf, runOf } from "./escalation-test-fixtures" +import { memberId, mixedTargetChain } from "./oncall-test-fixtures" const nameOf = (id: string) => Option.getOrElse( @@ -44,6 +45,23 @@ describe("chainSummary", () => { }) }) +describe("chainSummary with people and schedules", () => { + it("names the person and whoever is on call on the schedule, and reads generic words without names", () => { + const chain = mixedTargetChain(chainDestinations[0]?.id ?? "") + expect( + chainSummary(chain, nameOf, { + userName: (id) => (id === memberId(1) ? "Dana Kim" : "Ben Brook"), + scheduleName: () => "Checkout primary", + }), + ).toBe( + "Slack #alerts-api, then whoever is on call on Checkout primary and Dana Kim after 5m, then Ben Brook after 15m; repeats once", + ) + expect(chainSummary(chain, nameOf)).toBe( + "Slack #alerts-api, then the on-call and a team member after 5m, then a team member after 15m; repeats once", + ) + }) +}) + describe("formatWait", () => { it("reads minutes, hours and a day", () => { expect([formatWait(1), formatWait(60), formatWait(75), formatWait(1440)]).toEqual([ diff --git a/apps/web/src/page/alerts/shared/escalation-chains.ts b/apps/web/src/page/alerts/shared/escalation-chains.ts index 163455537e..42e8ce4995 100644 --- a/apps/web/src/page/alerts/shared/escalation-chains.ts +++ b/apps/web/src/page/alerts/shared/escalation-chains.ts @@ -1,11 +1,17 @@ -import type { AlertDestinationId, AlertIncidentId, EscalationChainId } from "@maple/domain/http" +import type { + AlertDestinationId, + AlertIncidentId, + EscalationChainId, + OnCallScheduleId, + UserId, +} from "@maple/domain/http" import type { V2AlertIncidentEscalation, V2EscalationChain, V2EscalationChainCreateParams, } from "@maple/domain/http/v2" import { formatAlertTime } from "@maple/web-data/alerts/form-utils" -import { Array as Arr, Effect } from "effect" +import { Array as Arr, Effect, Option } from "effect" import { toLoadFailure } from "@maple/views/data/load-failure" import { MapleApiV2Client } from "../../../platform/api-clients" import { AppRoute, hrefOf } from "../../../route" @@ -80,6 +86,12 @@ const repeatPhrase = (count: number): string => ? "; repeats twice" : `; repeats ${count} times` +/** How a summary names the people and schedules a step pages; without them, generic words. */ +export type TargetNames = Readonly<{ + userName?: (id: UserId) => string + scheduleName?: (id: OnCallScheduleId) => string +}> + /** * One line for a chain: "Slack #alerts-api, then PagerDuty after 10m, then email after 15m; * repeats twice". Each step waits for an ack before the next, so a step's delay is the one before it. @@ -87,6 +99,7 @@ const repeatPhrase = (count: number): string => export const chainSummary = ( chain: Pick, nameOf: (id: AlertDestinationId) => string, + people: TargetNames = {}, ): string => { const parts = Arr.map(chain.steps, (step, index) => { const names = namesOf( @@ -94,8 +107,11 @@ export const chainSummary = ( target.type === "destination" ? nameOf(target.destination_id) : target.type === "user" - ? "a team member" - : "the on-call", + ? (people.userName?.(target.user_id) ?? "a team member") + : Option.match(Option.fromNullishOr(people.scheduleName?.(target.schedule_id)), { + onNone: () => "the on-call", + onSome: (name) => `whoever is on call on ${name}`, + }), ), ) const previous = index === 0 ? undefined : chain.steps[index - 1] diff --git a/apps/web/src/page/alerts/shared/oncall-schedules.ts b/apps/web/src/page/alerts/shared/oncall-schedules.ts new file mode 100644 index 0000000000..5f0811aa27 --- /dev/null +++ b/apps/web/src/page/alerts/shared/oncall-schedules.ts @@ -0,0 +1,141 @@ +import type { OnCallOverrideId, OnCallScheduleId } from "@maple/domain/http" +import { + OnCallSchedulePublicId, + type V2OnCallOverrideCreateParams, + type V2OnCallSchedule, + type V2OnCallScheduleCreateParams, +} from "@maple/domain/http/v2" +import { Effect, Option, Schema } from "effect" +import { toLoadFailure } from "@maple/views/data/load-failure" +import { MapleApiV2Client } from "../../../platform/api-clients" +import { AppRoute, hrefOf } from "../../../route" +import type { AlertsScheduleNewSearch } from "../../../route/search" +import { isoMs, msIso } from "./time" + +// On-call schedule reads and writes as Effects the pages' Commands wrap, their links, and how a +// shift's edges read. + +/** An org holds at most 50 schedules, one v2 page. */ +export const SCHEDULE_LIST_LIMIT = 50 + +/** 409 on delete while an escalation chain still pages the schedule. */ +export const SCHEDULE_IN_USE_CODE = "oncall_schedule_in_use" + +export const listOnCallSchedules = Effect.gen(function* () { + const client = yield* MapleApiV2Client + const page = yield* client.oncallSchedules.list({ query: { limit: SCHEDULE_LIST_LIMIT } }) + return page.data +}).pipe(Effect.mapError(toLoadFailure)) + +export const retrieveOnCallSchedule = (id: OnCallScheduleId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.oncallSchedules.retrieve({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +/** Creates a schedule, or replaces `editingId`'s fields (overrides are kept). */ +export const saveOnCallSchedule = ( + editingId: OnCallScheduleId | null, + payload: V2OnCallScheduleCreateParams, +) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return editingId === null + ? yield* client.oncallSchedules.create({ payload }) + : yield* client.oncallSchedules.update({ + params: { id: editingId }, + payload: { ...payload, description: payload.description ?? null }, + }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const deleteOnCallSchedule = (id: OnCallScheduleId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + yield* client.oncallSchedules.delete({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +/** Each layer's shifts and the final ones over `[fromMs, toMs)`. */ +export const listShifts = (id: OnCallScheduleId, fromMs: number, toMs: number) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.oncallSchedules.shifts({ + params: { id }, + query: { from: msIso(fromMs), to: msIso(toMs) }, + }) + }).pipe(Effect.mapError(toLoadFailure)) + +/** The overrides that have not ended yet, by start. */ +export const listOverrides = (id: OnCallScheduleId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + const page = yield* client.oncallSchedules.listOverrides({ params: { id }, query: { limit: 100 } }) + return page.data + }).pipe(Effect.mapError(toLoadFailure)) + +export const createOverride = (id: OnCallScheduleId, payload: V2OnCallOverrideCreateParams) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.oncallSchedules.createOverride({ params: { id }, payload }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const deleteOverride = (id: OnCallScheduleId, overrideId: OnCallOverrideId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + yield* client.oncallSchedules.deleteOverride({ params: { id, override_id: overrideId } }) + }).pipe(Effect.mapError(toLoadFailure)) + +// LINKS + +const encodePublicId = Schema.encodeSync(OnCallSchedulePublicId) +const decodePublicId = Schema.decodeUnknownOption(OnCallSchedulePublicId) + +/** The `onc_…` id in a URL as the internal id. */ +export const scheduleIdFromUrl = (raw: string): Option.Option => decodePublicId(raw) + +export const scheduleHref = (id: OnCallScheduleId): string => + hrefOf(AppRoute.AlertsSchedule({ scheduleId: encodePublicId(id), search: {} })) + +export const newScheduleHref = (search: typeof AlertsScheduleNewSearch.Type = {}): string => + hrefOf(AppRoute.AlertsScheduleNew({ search })) + +export const editScheduleHref = (id: OnCallScheduleId): string => + newScheduleHref({ scheduleId: encodePublicId(id) }) + +export const oncallTabHref = (): string => hrefOf(AppRoute.Alerts({ search: { tab: "oncall" } })) + +// FORMAT + +const edgeFormatters = new Map() + +/** "Mon 16 Nov, 09:00" on the wall clock of `timeZone`. */ +export const formatShiftEdge = (ms: number, timeZone: string): string => { + const cached = edgeFormatters.get(timeZone) + const formatter = + cached ?? + new Intl.DateTimeFormat("en-GB", { + timeZone, + weekday: "short", + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }) + if (cached === undefined) edgeFormatters.set(timeZone, formatter) + return formatter.format(ms) +} + +/** "in 45m", "in 5h 10m", "in 3d 4h"; "now" once it has passed. */ +export const formatUntil = (nowMs: number, atMs: number): string => { + const minutes = Math.round((atMs - nowMs) / 60_000) + if (minutes <= 0) return "now" + if (minutes < 60) return `in ${minutes}m` + const hours = Math.floor(minutes / 60) + if (hours < 24) return minutes % 60 === 0 ? `in ${hours}h` : `in ${hours}h ${minutes % 60}m` + const days = Math.floor(hours / 24) + return hours % 24 === 0 ? `in ${days}d` : `in ${days}d ${hours % 24}h` +} + +/** When the current shift hands off, as epoch ms; none while nobody is on call. */ +export const handoffMs = (schedule: V2OnCallSchedule): Option.Option => + Option.map(Option.fromNullishOr(schedule.on_call_now), (now) => isoMs(now.ends_at)) diff --git a/apps/web/src/page/alerts/shared/oncall-test-fixtures.ts b/apps/web/src/page/alerts/shared/oncall-test-fixtures.ts new file mode 100644 index 0000000000..84c341354c --- /dev/null +++ b/apps/web/src/page/alerts/shared/oncall-test-fixtures.ts @@ -0,0 +1,458 @@ +import { + encodePublicId, + V2AlertIncidentEvent, + V2EscalationChain, + V2OnCallOverride, + V2OnCallSchedule, + V2OnCallShifts, +} from "@maple/domain/http/v2" +import { UserId } from "@maple/domain/http" +import { zonedDateParts, zonedPartsToEpochMs } from "@maple/query-engine/datetime" +import { Array as Arr, Option, Order, Schema, String as Str } from "effect" +import type { OrgMember } from "./org-members" +import { isoMs, msIso } from "./time" + +// On-call schedules, members, shifts and overrides for the scene tests and `/lab/oncall`. The +// shifts come from a small wall-clock generator, so a DST week reads the way the API renders it. + +const pad = (n: number) => Str.padStart(12, "0")(String(n)) +export const scheduleUuid = (n: number) => `0199a00f-0000-7000-8000-${pad(n)}` +export const overrideUuid = (n: number) => `0199a010-0000-7000-8000-${pad(n)}` +export const schedulePublicId = (n: number) => encodePublicId("onc", scheduleUuid(n)) + +/** Tue 10 Nov 2026, 10:30 UTC: the lab's and the tests' clock. */ +export const ONCALL_NOW = isoMs("2026-11-10T10:30:00.000Z") +/** Thu 22 Oct 2026, 10:00 UTC: a week whose Sunday (25 Oct) Berlin falls back an hour. */ +export const DST_NOW = isoMs("2026-10-22T10:00:00.000Z") + +const NAMES = [ + "Ben Brook", + "Dana Kim", + "Priya Raman", + "Lukas Weber", + "Sofia Alvarez", + "Mei Chen", + "Omar Haddad", + "Ana Souza", +] as const + +export const memberId = (index: number) => `user_2oncall${Str.padStart(4, "0")(String(index))}` + +export const oncallMembers: ReadonlyArray = Arr.map(NAMES, (name, index) => ({ + userId: Schema.decodeSync(UserId)(memberId(index)), + name, + email: `${Str.toLowerCase(Str.replaceAll(" ", ".")(name))}@acme.test`, + imageUrl: "", +})) + +// A tiny on-call engine over the zone's wall clock. + +type Segment = Readonly<{ userId: string; startMs: number; endMs: number }> +type Source = Readonly<{ kind: "layer"; layer_index: number } | { kind: "override"; override_id: string }> +type Shift = Segment & Readonly<{ source: Source }> + +const weekdayFormatter = (timeZone: string) => + new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short" }) + +/** `hour`:00 on the zone's day `dayOffset` days after the one containing `ms`. */ +const wallClock = (ms: number, timeZone: string, dayOffset: number, hour: number): number => { + const parts = zonedDateParts(ms, timeZone) + return zonedPartsToEpochMs({ ...parts, day: parts.day + dayOffset, hour, minute: 0, second: 0 }, timeZone) +} + +const DAYS = Arr.range(-8, 16) +const DAY_MS = 86_400_000 + +/** Whose turn a shift starting at `startMs` is: counted from the epoch, so any range agrees. */ +const turnOf = (startMs: number, periodMs: number) => Math.floor((startMs + 43_200_000) / periodMs) + +/** Hand-offs at `hour` on the days `isHandoffDay` keeps, people taking turns from `fromMs`'s week. */ +const rotation = ( + participants: ReadonlyArray, + timeZone: string, + fromMs: number, + hour: number, + isHandoffDay: (weekday: string) => boolean, + periodMs: number, +): ReadonlyArray => { + const weekday = weekdayFormatter(timeZone) + const edges = Arr.filter( + Arr.map(DAYS, (day) => wallClock(fromMs, timeZone, day, hour)), + (at) => isHandoffDay(weekday.format(at)), + ) + return Arr.flatMap(edges, (startMs, index) => { + const endMs = edges[index + 1] + const userId = participants[turnOf(startMs, periodMs) % participants.length] + return endMs === undefined || userId === undefined ? [] : [{ userId, startMs, endMs }] + }) +} + +/** A window from `startHour` to `endHour` the next morning on each kept day, people alternating. */ +const nights = ( + participants: ReadonlyArray, + timeZone: string, + fromMs: number, + startHour: number, + endHour: number, + isDay: (weekday: string) => boolean, +): ReadonlyArray => { + const weekday = weekdayFormatter(timeZone) + return Arr.flatMap(DAYS, (day) => { + const startMs = wallClock(fromMs, timeZone, day, startHour) + const userId = participants[turnOf(startMs, DAY_MS) % participants.length] + return isDay(weekday.format(startMs)) && userId !== undefined + ? [{ userId, startMs, endMs: wallClock(fromMs, timeZone, day + 1, endHour) }] + : [] + }) +} + +const clip = ( + segments: ReadonlyArray, + fromMs: number, + toMs: number, +): ReadonlyArray => + Arr.flatMap(segments, (segment) => { + const startMs = Math.max(segment.startMs, fromMs) + const endMs = Math.min(segment.endMs, toMs) + return endMs > startMs ? [{ ...segment, startMs, endMs }] : [] + }) + +const covering = (segments: ReadonlyArray, at: number) => + Arr.findLast(segments, (segment) => segment.startMs <= at && at < segment.endMs) + +const sameSource = (a: Source, b: Source): boolean => + a.kind === "layer" + ? b.kind === "layer" && a.layer_index === b.layer_index + : b.kind === "override" && a.override_id === b.override_id + +/** Layers stacked bottom first, the top one with someone on call winning, overrides over all. */ +const flatten = ( + layers: ReadonlyArray>, + overrides: ReadonlyArray>, + fromMs: number, + toMs: number, +): ReadonlyArray => { + const edges = Arr.sort( + Arr.dedupe([ + fromMs, + toMs, + ...Arr.flatMap([...layers, overrides], (segments) => + Arr.flatMap(segments, (segment) => [segment.startMs, segment.endMs]), + ), + ]), + Order.Number, + ) + const pieces = Arr.flatMap(edges, (startMs, index): ReadonlyArray => { + const endMs = edges[index + 1] + if (endMs === undefined || startMs < fromMs || endMs > toMs) return [] + const mid = (startMs + endMs) / 2 + const override = Arr.findLast(overrides, (segment) => segment.startMs <= mid && mid < segment.endMs) + if (Option.isSome(override)) + return [ + { + userId: override.value.userId, + startMs, + endMs, + source: { kind: "override", override_id: override.value.id }, + }, + ] + const top = Arr.findLast( + Arr.map(layers, (segments, layerIndex) => ({ layerIndex, hit: covering(segments, mid) })), + ({ hit }) => Option.isSome(hit), + ) + return Option.match(top, { + onNone: () => [], + onSome: ({ layerIndex, hit }) => + Option.match(hit, { + onNone: () => [], + onSome: (segment) => [ + { + userId: segment.userId, + startMs, + endMs, + source: { kind: "layer", layer_index: layerIndex }, + }, + ], + }), + }) + }) + const none: ReadonlyArray = [] + return Arr.reduce(pieces, none, (merged, piece) => { + const last = merged[merged.length - 1] + return last !== undefined && + last.endMs === piece.startMs && + last.userId === piece.userId && + sameSource(last.source, piece.source) + ? [...merged.slice(0, -1), { ...last, endMs: piece.endMs }] + : [...merged, piece] + }) +} + +// WIRE + +const iso = (ms: number) => msIso(ms) +const decodeSchedule = Schema.decodeUnknownSync(V2OnCallSchedule) +const decodeShifts = Schema.decodeUnknownSync(V2OnCallShifts) +const decodeOverride = Schema.decodeUnknownSync(V2OnCallOverride) +const decodeEvent = Schema.decodeUnknownSync(V2AlertIncidentEvent) + +const wireShift = (shift: Shift) => ({ + user_id: shift.userId, + starts_at: iso(shift.startMs), + ends_at: iso(shift.endMs), + source: + shift.source.kind === "layer" + ? shift.source + : { kind: "override", override_id: encodePublicId("ovr", shift.source.override_id) }, +}) + +/** One fixture schedule: its layers as the API holds them, and how to render its shifts. */ +type Spec = Readonly<{ + n: number + name: string + timeZone: string + layers: ReadonlyArray + segments: (fromMs: number) => ReadonlyArray> + overrides: (nowMs: number) => ReadonlyArray> +}> + +const m = (index: number) => memberId(index) +const isWeekday = (day: string) => day !== "Sat" && day !== "Sun" +const isWeekend = (day: string) => day === "Sat" || day === "Sun" + +const checkout: Spec = { + n: 1, + name: "Checkout primary", + timeZone: "Europe/Berlin", + layers: [ + { + name: "Primary", + participants: [m(0), m(1), m(2)], + rotation: { kind: "weekly", handoff_weekday: "mon", handoff_time: "09:00" }, + starts_at: "2026-10-05T07:00:00.000Z", + }, + { + name: "Daytime backup", + participants: [m(3), m(4)], + rotation: { kind: "daily", handoff_time: "09:00" }, + starts_at: "2026-10-05T07:00:00.000Z", + restrictions: [ + { weekdays: ["mon", "tue", "wed", "thu", "fri"], start_time: "09:00", end_time: "17:00" }, + ], + }, + { + name: "Weeknights", + participants: [m(5), m(6)], + rotation: { kind: "daily", handoff_time: "18:00" }, + starts_at: "2026-10-05T16:00:00.000Z", + restrictions: [ + { weekdays: ["mon", "tue", "wed", "thu", "fri"], start_time: "18:00", end_time: "08:00" }, + ], + }, + ], + segments: (fromMs) => [ + rotation([m(0), m(1), m(2)], "Europe/Berlin", fromMs, 9, (day) => day === "Mon", 7 * DAY_MS), + Arr.filter( + Arr.map( + rotation([m(3), m(4)], "Europe/Berlin", fromMs, 9, () => true, DAY_MS), + (segment) => ({ + ...segment, + endMs: segment.startMs + 8 * 3_600_000, + }), + ), + (segment) => isWeekday(weekdayFormatter("Europe/Berlin").format(segment.startMs)), + ), + nights([m(5), m(6)], "Europe/Berlin", fromMs, 18, 8, isWeekday), + ], + overrides: (nowMs) => [ + { + id: overrideUuid(1), + userId: m(7), + startMs: wallClock(nowMs, "Europe/Berlin", 1, 18), + endMs: wallClock(nowMs, "Europe/Berlin", 2, 8), + }, + { + id: overrideUuid(2), + userId: m(1), + startMs: wallClock(nowMs, "Europe/Berlin", 3, 12), + endMs: wallClock(nowMs, "Europe/Berlin", 4, 12), + }, + ], +} + +const longName: Spec = { + n: 2, + name: "Payments platform and ledger reconciliation, North America business hours rotation", + timeZone: "America/New_York", + layers: [ + { + name: "Business hours", + participants: [m(4), m(2), m(6)], + rotation: { kind: "custom", length_hours: 12 }, + starts_at: "2026-11-02T13:00:00.000Z", + }, + ], + segments: (fromMs) => [rotation([m(4), m(2), m(6)], "America/New_York", fromMs, 9, () => true, DAY_MS)], + overrides: () => [], +} + +/** Weekends only: on a weekday nobody is on call. */ +const weekends: Spec = { + n: 3, + name: "Weekend coverage", + timeZone: "UTC", + layers: [ + { + name: "Weekends", + participants: [m(7), m(3)], + rotation: { kind: "weekly", handoff_weekday: "sat", handoff_time: "00:00" }, + starts_at: "2026-10-03T00:00:00.000Z", + restrictions: [{ weekdays: ["sat", "sun"], start_time: "00:00", end_time: "00:00" }], + }, + ], + segments: (fromMs) => [ + Arr.filter( + rotation([m(7), m(3)], "UTC", fromMs, 0, () => true, DAY_MS), + (segment) => isWeekend(weekdayFormatter("UTC").format(segment.startMs)), + ), + ], + overrides: () => [], +} + +const SPECS: ReadonlyArray = [checkout, longName, weekends] + +const WEEK_MS = 7 * 86_400_000 + +/** The schedule as `GET /oncall_schedules` returns it, with who is on call at `nowMs`. */ +const scheduleOf = (spec: Spec, nowMs: number): V2OnCallSchedule => { + const final = flatten( + spec.segments(nowMs - WEEK_MS), + spec.overrides(nowMs), + nowMs - WEEK_MS, + nowMs + WEEK_MS, + ) + const current = Arr.findFirst(final, (shift) => shift.startMs <= nowMs && nowMs < shift.endMs) + return decodeSchedule({ + id: encodePublicId("onc", scheduleUuid(spec.n)), + object: "oncall_schedule", + name: spec.name, + description: null, + time_zone: spec.timeZone, + layers: spec.layers, + on_call_now: Option.getOrNull( + Option.map(current, (shift) => ({ + user_id: shift.userId, + starts_at: iso(shift.startMs), + ends_at: iso(shift.endMs), + })), + ), + chain_ids: spec.n === 1 ? [encodePublicId("esc", "0199a00e-0000-7000-8000-000000000001")] : [], + created_at: iso(nowMs - 30 * 86_400_000), + updated_at: iso(nowMs - 86_400_000), + }) +} + +/** Three schedules: one with three layers and overrides, one with a long name, one in a gap. */ +export const populatedSchedules = (nowMs: number = ONCALL_NOW): ReadonlyArray => + Arr.map(SPECS, (spec) => scheduleOf(spec, nowMs)) + +export const checkoutSchedule = (nowMs: number = ONCALL_NOW): V2OnCallSchedule => scheduleOf(checkout, nowMs) + +/** `GET /{id}/shifts` for the checkout schedule over `[fromMs, toMs)`. */ +export const checkoutShifts = (nowMs: number, fromMs: number, toMs: number): V2OnCallShifts => { + const layers = Arr.map(checkout.segments(fromMs), (segments) => clip(segments, fromMs, toMs)) + const overrides = checkout.overrides(nowMs) + return decodeShifts({ + object: "oncall_shifts", + schedule_id: encodePublicId("onc", scheduleUuid(1)), + from: iso(fromMs), + to: iso(toMs), + layers: Arr.map(layers, (segments, layerIndex) => + Arr.map(segments, (segment) => + wireShift({ ...segment, source: { kind: "layer", layer_index: layerIndex } }), + ), + ), + final: Arr.map(flatten(layers, clip(overrides, fromMs, toMs), fromMs, toMs), wireShift), + }) +} + +/** The checkout schedule's upcoming overrides. */ +export const checkoutOverrides = (nowMs: number = ONCALL_NOW): ReadonlyArray => + Arr.map(checkout.overrides(nowMs), (override) => + decodeOverride({ + id: encodePublicId("ovr", override.id), + object: "oncall_override", + schedule_id: encodePublicId("onc", scheduleUuid(1)), + user_id: override.userId, + starts_at: iso(override.startMs), + ends_at: iso(override.endMs), + created_by: m(0), + created_at: iso(nowMs - 86_400_000), + }), + ) + +const eventUuid = (n: number) => `0199a011-0000-7000-8000-${pad(n)}` + +/** `paged` incident events: one sent, one failed, one still on its way. */ +export const pagedEvents = (incidentId: string, atMs: number): ReadonlyArray => + Arr.map( + [ + { n: 1, user: 0, channel: "slack", status: "sent", error: null, minutes: 1 }, + { n: 2, user: 0, channel: "email", status: "sent", error: null, minutes: 1 }, + { + n: 3, + user: 1, + channel: "discord", + status: "failed", + error: "Dana has not linked a Discord account", + minutes: 11, + }, + { n: 4, user: 1, channel: "email", status: "sending", error: null, minutes: 11 }, + ], + (event) => + decodeEvent({ + id: encodePublicId("evt", eventUuid(event.n)), + object: "alert_incident_event", + incident_id: encodePublicId("inc", incidentId), + type: "paged", + actor: null, + source: null, + page: { + user_id: memberId(event.user), + name: NAMES[event.user] ?? null, + channel: event.channel, + status: event.status, + error_message: event.error, + }, + created_at: iso(atMs + event.minutes * 60_000), + }), + ) + +const decodeChain = Schema.decodeUnknownSync(V2EscalationChain) + +/** A chain built through the API: a destination, then a person and a schedule, then a person. */ +export const mixedTargetChain = (destinationId: string): V2EscalationChain => + decodeChain({ + id: encodePublicId("esc", "0199a00e-0000-7000-8000-000000000001"), + object: "escalation_chain", + name: "Checkout on-call", + description: null, + steps: [ + { + targets: [{ type: "destination", destination_id: encodePublicId("dest", destinationId) }], + wait_minutes: 5, + }, + { + targets: [ + { type: "schedule", schedule_id: schedulePublicId(1) }, + { type: "user", user_id: memberId(1) }, + ], + wait_minutes: 15, + }, + { targets: [{ type: "user", user_id: memberId(0) }], wait_minutes: 30 }, + ], + repeat_count: 1, + rule_ids: [], + created_at: iso(ONCALL_NOW), + updated_at: iso(ONCALL_NOW), + }) diff --git a/apps/web/src/page/alerts/shared/org-members.ts b/apps/web/src/page/alerts/shared/org-members.ts new file mode 100644 index 0000000000..83c9466d36 --- /dev/null +++ b/apps/web/src/page/alerts/shared/org-members.ts @@ -0,0 +1,67 @@ +import { UserId } from "@maple/domain/http" +import { Array as Arr, Effect, Option, Schema } from "effect" +import { clerkTry, currentClerkOrganization } from "../../../platform/clerk" + +// The org's members as the on-call pickers and names read them: Clerk's memberships, first 100. + +const PAGE_SIZE = 100 + +export const OrgMember = Schema.Struct({ + userId: UserId, + name: Schema.String, + email: Schema.String, + /** `""` without an uploaded image: the warm initials tile stands in. */ + imageUrl: Schema.String, +}) +export type OrgMember = typeof OrgMember.Type + +const decodeUserId = Schema.decodeUnknownOption(UserId) + +/** Every member with a user id, named by first and last name, else by email. */ +export const listOrgMembers: Effect.Effect, string> = currentClerkOrganization.pipe( + Effect.flatMap((organization) => + clerkTry("Couldn't load members", () => + organization.getMemberships({ initialPage: 1, pageSize: PAGE_SIZE }), + ), + ), + Effect.mapBoth({ + onSuccess: (page) => + Arr.flatMap(page.data, (member): ReadonlyArray => { + const user = member.publicUserData + const email = user?.identifier ?? "" + const name = Arr.join( + Arr.filter([user?.firstName, user?.lastName], (part): part is string => Boolean(part)), + " ", + ) + return Option.match(decodeUserId(user?.userId), { + onNone: () => [], + onSome: (userId) => [ + { + userId, + name: name || email || "Unknown member", + email, + imageUrl: user?.hasImage === true ? user.imageUrl : "", + }, + ], + }) + }), + onFailure: (error) => error.message, + }), +) + +/** A member by id; someone who left the org reads as "A former member", and "Member" until they load. */ +export const memberName = (members: ReadonlyArray | null, userId: string): string => + members === null + ? "Member" + : Option.getOrElse( + Option.map( + Arr.findFirst(members, (member) => member.userId === userId), + (member) => member.name, + ), + () => "A former member", + ) + +export const memberOf = ( + members: ReadonlyArray | null, + userId: string, +): Option.Option => Arr.findFirst(members ?? [], (member) => member.userId === userId) diff --git a/apps/web/src/page/registry.ts b/apps/web/src/page/registry.ts index f5b22298e9..5340caad8d 100644 --- a/apps/web/src/page/registry.ts +++ b/apps/web/src/page/registry.ts @@ -30,6 +30,8 @@ import * as AlertsUptimeMonitor from "./alerts/uptime-monitor" import * as AlertsUptimeNew from "./alerts/uptime-form" import * as AlertsSlo from "./alerts/slo" import * as AlertsSloNew from "./alerts/slo-form" +import * as AlertsSchedule from "./alerts/schedule" +import * as AlertsScheduleNew from "./alerts/schedule-form" import * as IngestRuleEditor from "./settings/ingest-rule" import * as DashboardsList from "./dashboards/list" import * as DashboardTemplates from "./dashboards/templates" @@ -123,6 +125,8 @@ export const pages = [ AlertsUptimeNew.page, AlertsSlo.page, AlertsSloNew.page, + AlertsSchedule.page, + AlertsScheduleNew.page, StatusPagesList.page, StatusPageBuilder.page, StatusPageEditor.page, diff --git a/apps/web/src/route/app-route.ts b/apps/web/src/route/app-route.ts index 3d76b019e3..6cc1190873 100644 --- a/apps/web/src/route/app-route.ts +++ b/apps/web/src/route/app-route.ts @@ -34,6 +34,8 @@ export const AppRoute = defineRouteUnion({ AlertsUptimeMonitor: { monitorId: Schema.String, search: Search.AlertsUptimeMonitorSearch }, AlertsSloNew: { search: Search.AlertsSloNewSearch }, AlertsSlo: { sloId: Schema.String, search: Search.AlertsSloSearch }, + AlertsScheduleNew: { search: Search.AlertsScheduleNewSearch }, + AlertsSchedule: { scheduleId: Schema.String, search: Search.AlertsScheduleSearch }, IngestRuleNew: { search: Search.IngestRuleNewSearch }, IngestRule: { ruleId: Schema.String }, AlertsStatusPage: { pageId: Schema.String, search: Search.AlertsStatusPageSearch }, diff --git a/apps/web/src/route/routers.ts b/apps/web/src/route/routers.ts index 2e5d718c41..08e3c2da40 100644 --- a/apps/web/src/route/routers.ts +++ b/apps/web/src/route/routers.ts @@ -103,6 +103,17 @@ const routers = { search(Search.AlertsSloSearch), Route.mapTo(AppRoute.AlertsSlo), ), + AlertsScheduleNew: pipe( + path("alerts", "schedules", "new"), + search(Search.AlertsScheduleNewSearch), + Route.mapTo(AppRoute.AlertsScheduleNew), + ), + AlertsSchedule: pipe( + path("alerts", "schedules"), + slash(segment("scheduleId")), + search(Search.AlertsScheduleSearch), + Route.mapTo(AppRoute.AlertsSchedule), + ), IngestRuleNew: pipe( path("settings", "ingestion", "rules", "new"), search(Search.IngestRuleNewSearch), @@ -515,6 +526,8 @@ export const urlToAppRoute = Route.parseUrlWithFallback( routers.AlertsUptimeMonitor, routers.AlertsSloNew, routers.AlertsSlo, + routers.AlertsScheduleNew, + routers.AlertsSchedule, routers.IngestRuleNew, routers.IngestRule, routers.AlertsStatusPage, diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index 7803dd621d..c8f65c0366 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -372,6 +372,13 @@ export const AlertsSloNewSearch = Schema.Struct({ export const AlertsSloSearch = Schema.Struct({}) +/** `/alerts/schedules/new`: `scheduleId` edits that on-call schedule. */ +export const AlertsScheduleNewSearch = Schema.Struct({ + scheduleId: Schema.optional(Schema.String), +}) + +export const AlertsScheduleSearch = Schema.Struct({}) + /** `/settings/ingestion/rules/new`: `template` prefills the editor from an empty-state template. */ export const IngestRuleNewSearch = Schema.Struct({ template: Schema.optional(Schema.String), From 0f0e37230a3e3d41b115f192aeb5c14f05c1e64e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:47:09 +0100 Subject: [PATCH 44/98] feat(alerts): the incident timeline shows who an escalation paged, and failed pages --- apps/web/src/page/alerts/incident/command.ts | 16 +++++ .../incident/incident-paged.scene.test.ts | 69 +++++++++++++++++++ apps/web/src/page/alerts/incident/message.ts | 12 +++- apps/web/src/page/alerts/incident/model.ts | 10 ++- apps/web/src/page/alerts/incident/update.ts | 24 +++++++ apps/web/src/page/alerts/incident/view.ts | 21 +++++- .../src/alerts/incident-timeline.test.ts | 46 ++++++++++++- .../web-data/src/alerts/incident-timeline.ts | 56 +++++++++++++++ 8 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/page/alerts/incident/incident-paged.scene.test.ts diff --git a/apps/web/src/page/alerts/incident/command.ts b/apps/web/src/page/alerts/incident/command.ts index 7ef35a5820..7355dc8639 100644 --- a/apps/web/src/page/alerts/incident/command.ts +++ b/apps/web/src/page/alerts/incident/command.ts @@ -112,3 +112,19 @@ export const FetchIncidentMonitors = Command.define("FetchAlertIncidentMonitors" Effect.map((result) => Message.CompletedFetchMonitors({ key, result })), ), }) + +/** The incident's activity, oldest first: the timeline reads its `paged` events. */ +export const FetchIncidentEvents = Command.define("FetchAlertIncidentEvents", { + args: { key: Schema.String, incidentId: AlertIncidentId }, + messages: [Message.CompletedFetchEvents], + execute: ({ key, incidentId }) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + const list = yield* client.alertIncidents.listEvents({ params: { id: incidentId } }) + return list.data + }).pipe( + Effect.mapError(toLoadFailure), + Effect.result, + Effect.map((result) => Message.CompletedFetchEvents({ key, result })), + ), +}) diff --git a/apps/web/src/page/alerts/incident/incident-paged.scene.test.ts b/apps/web/src/page/alerts/incident/incident-paged.scene.test.ts new file mode 100644 index 0000000000..b258b1df25 --- /dev/null +++ b/apps/web/src/page/alerts/incident/incident-paged.scene.test.ts @@ -0,0 +1,69 @@ +import { OrgId } from "@maple/domain" +import { Result, Schema, Array as Arr } from "effect" +import { expect as expectView, given, Mount, scene, text, withViewInputs } from "foldkit/scene" +import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" +import { describe, expect, test } from "vitest" +import type { PageContext } from "../../contract" +import { pagedEvents } from "../shared/oncall-test-fixtures" +import { incidentRow, INCIDENT_ID, NOW, ORG, ruleRow } 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 names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) +const route = { _tag: "AlertIncident" as const, incidentId: INCIDENT_ID, search: {} } +const run = (model: Model, messages: ReadonlyArray) => + Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) + +describe("alert incident, pages to people", () => { + test("the incident reads its events with the escalation, and again when it is acknowledged", () => { + const entered = run(init(route, context).model, [Message.ReadClock({ nowMs: NOW })]) + const first = pageUpdate(entered, Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow()] })) + expect(names(first.commands)).toContain("FetchAlertIncidentEvents") + const same = pageUpdate(first.model, Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow()] })) + expect(names(same.commands)).not.toContain("FetchAlertIncidentEvents") + const acked = pageUpdate( + first.model, + Message.UpdatedIncidents({ + orgId: ORG, + rows: [incidentRow({ acknowledged_at: "2026-10-01T11:58:00.000Z" })], + }), + ) + expect(names(acked.commands)).toContain("FetchAlertIncidentEvents") + }) + + test("the timeline says who was paged on which channel, and why a page failed", () => { + const row = incidentRow() + const synced = run(init(route, context).model, [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedIncidents({ orgId: ORG, rows: [row] }), + Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(2)] }), + ]) + const model = run(synced, [ + Message.CompletedFetchEvents({ + key: synced.events.key ?? "", + result: Result.succeed(pagedEvents(INCIDENT_ID, Date.parse(row.first_triggered_at))), + }), + Message.CompletedFetchDeliveries({ + key: synced.deliveries.key ?? "", + result: Result.succeed([]), + }), + ]) + scene( + { update: pageUpdate, view }, + given(model), + Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })), + expectView(text("Paged Ben Brook by Slack DM")).toExist(), + expectView(text("Paged Ben Brook by email")).toExist(), + expectView(text("Couldn't page Dana Kim by Discord DM")).toExist(), + expectView(text("Dana has not linked a Discord account")).toExist(), + expectView(text("Paging Dana Kim by email")).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/incident/message.ts b/apps/web/src/page/alerts/incident/message.ts index b65d66cce4..68fcc5faff 100644 --- a/apps/web/src/page/alerts/incident/message.ts +++ b/apps/web/src/page/alerts/incident/message.ts @@ -1,5 +1,11 @@ import { AlertDeliveryEventDocument, AlertRulePreviewResponse } from "@maple/domain/http" -import { V2AlertIncidentEscalation, V2Investigation, V2Slo, V2UptimeMonitor } from "@maple/domain/http/v2" +import { + V2AlertIncidentEscalation, + V2AlertIncidentEvent, + V2Investigation, + V2Slo, + V2UptimeMonitor, +} from "@maple/domain/http/v2" import { AlertIncidentRowSchema, AlertRuleRowSchema, @@ -37,6 +43,10 @@ export const Message = defineMessageUnion({ key: Schema.String, result: Schema.Result(Schema.NullOr(V2AlertIncidentEscalation), LoadFailure), }, + CompletedFetchEvents: { + key: Schema.String, + result: Schema.Result(Schema.Array(V2AlertIncidentEvent), LoadFailure), + }, CompletedFetchSlos: { key: Schema.String, result: Schema.Result(Schema.Array(V2Slo), LoadFailure) }, CompletedFetchMonitors: { key: Schema.String, diff --git a/apps/web/src/page/alerts/incident/model.ts b/apps/web/src/page/alerts/incident/model.ts index 01525045d3..9464d0ed6d 100644 --- a/apps/web/src/page/alerts/incident/model.ts +++ b/apps/web/src/page/alerts/incident/model.ts @@ -1,5 +1,11 @@ import { AlertDeliveryEventDocument } from "@maple/domain/http" -import { V2AlertIncidentEscalation, V2Investigation, V2Slo, V2UptimeMonitor } from "@maple/domain/http/v2" +import { + V2AlertIncidentEscalation, + V2AlertIncidentEvent, + V2Investigation, + V2Slo, + V2UptimeMonitor, +} from "@maple/domain/http/v2" import { AlertIncidentRowSchema, AlertRuleRowSchema, @@ -32,6 +38,8 @@ export const Model = Schema.Struct({ investigation: KeyedSchema(Schema.NullOr(V2Investigation), LoadFailure), /** The incident's latest escalation run (`alertIncidents.retrieve`), re-read as the incident changes. */ escalation: KeyedSchema(Schema.NullOr(V2AlertIncidentEscalation), LoadFailure), + /** `alertIncidents.listEvents`: the timeline's pages to members, re-read with the escalation. */ + events: KeyedSchema(Schema.Array(V2AlertIncidentEvent), LoadFailure), /** An SLO incident's link to its SLO: `slos.list`, read only for one. */ slos: KeyedSchema(Schema.Array(V2Slo), LoadFailure), /** An uptime incident's link to its monitor: `uptimeMonitors.list`, read only for one. */ diff --git a/apps/web/src/page/alerts/incident/update.ts b/apps/web/src/page/alerts/incident/update.ts index 4fc69b1f59..b6088ba0a9 100644 --- a/apps/web/src/page/alerts/incident/update.ts +++ b/apps/web/src/page/alerts/incident/update.ts @@ -20,6 +20,7 @@ import { FetchIncidentChecks, FetchIncidentDeliveries, FetchIncidentEscalation, + FetchIncidentEvents, FetchIncidentInvestigation, FetchIncidentMonitors, FetchIncidentPreview, @@ -48,6 +49,7 @@ export const init = (route: IncidentRoute, _context: PageContext): Return => ({ deliveries: Keyed.idle(), investigation: Keyed.idle(), escalation: Keyed.idle(), + events: Keyed.idle(), slos: Keyed.idle(), monitors: Keyed.idle(), isStartingInvestigation: false, @@ -97,6 +99,14 @@ const followData = (model: Model, context: PageContext): Return => lastNotifiedAt: view.incident.lastNotifiedAt, acknowledgedAt: view.incident.acknowledgedAt, }) + // Pages land as the escalation moves, so the events follow the same changes. + const eventsKey = requestKey(context.orgId, "alertIncidents.listEvents", { + id: incidentId, + status: view.incident.status, + severity: view.incident.severity, + lastNotifiedAt: view.incident.lastNotifiedAt, + acknowledgedAt: view.incident.acknowledgedAt, + }) const steps: ReadonlyArray<{ patch: Partial; commands: Commands }> = [ ...Option.toArray( Option.map( @@ -109,6 +119,17 @@ const followData = (model: Model, context: PageContext): Return => }), ), ), + ...Option.toArray( + Option.map( + sameKey(model.events, eventsKey) + ? Option.none() + : Keyed.request(model.events, eventsKey, "Load"), + (events) => ({ + patch: { events }, + commands: [FetchIncidentEvents({ key: eventsKey, incidentId })], + }), + ), + ), ...Option.toArray( Option.map( sameKey(model.deliveries, deliveriesKey) @@ -312,6 +333,9 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedFetchEscalation: ({ key, result }) => ({ model: { ...model, escalation: Keyed.accept(model.escalation, key, result) }, }), + CompletedFetchEvents: ({ key, result }) => ({ + model: { ...model, events: Keyed.accept(model.events, key, result) }, + }), CompletedFetchSlos: ({ key, result }) => ({ model: { ...model, slos: Keyed.accept(model.slos, key, result) }, }), diff --git a/apps/web/src/page/alerts/incident/view.ts b/apps/web/src/page/alerts/incident/view.ts index 855f7d9596..809da00149 100644 --- a/apps/web/src/page/alerts/incident/view.ts +++ b/apps/web/src/page/alerts/incident/view.ts @@ -1,4 +1,4 @@ -import type { V2AlertIncidentEscalation } from "@maple/domain/http/v2" +import type { V2AlertIncidentEscalation, V2AlertIncidentEvent } from "@maple/domain/http/v2" import type { AlertCheckDocument, AlertIncidentDocument, @@ -15,6 +15,7 @@ import { } from "@maple/web-data/alerts/form-utils" import { buildIncidentTimeline, + type IncidentPage, conditionPhrase, holdReasonLabel, incidentGroup, @@ -321,6 +322,7 @@ const body = (h: H, model: Model, view: IncidentView, timeZone: string, now: num incident, deliveries, investigation, + pages: pagesOf(AsyncData.getOrElse(model.events.data, () => [])), renotifyIntervalMinutes: rule != null && rule.destinationIds.length > 0 ? rule.renotifyIntervalMinutes : null, destinationTypeLabel: (type) => providerOf(type).label, @@ -335,6 +337,23 @@ const body = (h: H, model: Model, view: IncidentView, timeZone: string, now: num }) } +/** The incident's `paged` events as the timeline reads them. */ +const pagesOf = (events: ReadonlyArray): ReadonlyArray => + Arr.flatMap(events, (event): ReadonlyArray => + event.type !== "paged" || event.page === undefined + ? [] + : [ + { + id: event.id, + at: event.created_at, + name: event.page.name, + channel: event.page.channel, + status: event.page.status, + errorMessage: event.page.error_message, + }, + ], + ) + const loading = (h: H): Html => dashboardPage( h, diff --git a/packages/web-data/src/alerts/incident-timeline.test.ts b/packages/web-data/src/alerts/incident-timeline.test.ts index d13de42ccf..994edfa241 100644 --- a/packages/web-data/src/alerts/incident-timeline.test.ts +++ b/packages/web-data/src/alerts/incident-timeline.test.ts @@ -1,5 +1,5 @@ import { AlertDeliveryEventDocument, AlertIncidentDocument } from "@maple/domain/http" -import { Option, Schema, Array as Arr } from "effect" +import { Option, Schema, Array as Arr, String as Str } from "effect" import { describe, expect, it } from "vitest" import { buildIncidentTimeline } from "./incident-timeline" @@ -137,3 +137,47 @@ describe("buildIncidentTimeline", () => { expect(entries.at(-1)).toMatchObject({ title: "Resolved", detail: "After 38m" }) }) }) + +describe("paged events", () => { + it("read who was paged on which channel, in order, with a failure's reason", () => { + const entries = buildIncidentTimeline({ + incident: makeIncident(), + deliveries: [], + investigation: null, + pages: [ + { + id: "p2", + at: "2026-10-15T14:20:00.000Z", + name: "Ben Brook", + channel: "email", + status: "failed", + errorMessage: "Mailbox full", + }, + { + id: "p1", + at: "2026-10-15T14:15:00.000Z", + name: "Ben Brook", + channel: "slack", + status: "sent", + errorMessage: null, + }, + { + id: "p3", + at: "2026-10-15T14:21:00.000Z", + name: null, + channel: "discord", + status: "skipped", + errorMessage: null, + }, + ], + renotifyIntervalMinutes: null, + destinationTypeLabel: (type) => type, + }) + const pages = Arr.filter(entries, (entry) => Str.startsWith("page:")(entry.key)) + expect(Arr.map(pages, (entry) => [entry.title, entry.detail, entry.tone])).toEqual([ + ["Paged Ben Brook by Slack DM", null, "info"], + ["Couldn't page Ben Brook by email", "Mailbox full", "crit"], + ["Skipped paging a former member by Discord DM", "Acknowledged or resolved first", "neutral"], + ]) + }) +}) diff --git a/packages/web-data/src/alerts/incident-timeline.ts b/packages/web-data/src/alerts/incident-timeline.ts index a7d927609d..18bdbab851 100644 --- a/packages/web-data/src/alerts/incident-timeline.ts +++ b/packages/web-data/src/alerts/incident-timeline.ts @@ -111,6 +111,59 @@ function deliveryEntry( } } +/** A `paged` incident event: an escalation step paged one member on one channel. */ +export interface IncidentPage { + readonly id: string + /** ISO timestamp. */ + readonly at: string + readonly name: string | null + readonly channel: string + readonly status: "queued" | "sending" | "sent" | "failed" | "skipped" + readonly errorMessage: string | null +} + +/** "email", "Slack DM", "Discord DM". */ +export function pageChannelLabel(channel: string): string { + switch (channel) { + case "email": + return "email" + case "slack": + return "Slack DM" + case "discord": + return "Discord DM" + default: + return `${channel} DM` + } +} + +/** "Paged Ben Brook by Slack DM"; a failed or skipped page says so and why. */ +export function pageEntry(page: IncidentPage): IncidentTimelineEntry { + const who = page.name ?? "a former member" + const by = pageChannelLabel(page.channel) + const sending = page.status === "queued" || page.status === "sending" + const title = + page.status === "failed" + ? `Couldn't page ${who} by ${by}` + : page.status === "skipped" + ? `Skipped paging ${who} by ${by}` + : sending + ? `Paging ${who} by ${by}` + : `Paged ${who} by ${by}` + return { + key: `page:${page.id}`, + at: Date.parse(page.at), + tone: page.status === "failed" ? "crit" : page.status === "skipped" || sending ? "neutral" : "info", + pending: sending, + title, + detail: + page.status === "failed" || page.status === "skipped" + ? (page.errorMessage ?? + (page.status === "skipped" ? "Acknowledged or resolved first" : "The channel refused it")) + : null, + investigation: null, + } +} + function investigationEntries(investigation: V2Investigation): ReadonlyArray { const started: IncidentTimelineEntry = { key: `investigation:${investigation.id}:started`, @@ -165,6 +218,8 @@ export function buildIncidentTimeline(input: { incident: AlertIncidentDocument deliveries: ReadonlyArray investigation: V2Investigation | null + /** Escalation pages to members, from the incident's `paged` events. */ + pages?: ReadonlyArray renotifyIntervalMinutes: number | null destinationTypeLabel: (type: AlertDestinationType) => string }): ReadonlyArray { @@ -193,6 +248,7 @@ export function buildIncidentTimeline(input: { const entry = deliveryEntry(delivery, input.destinationTypeLabel) if (entry != null) events.push(entry) } + for (const page of input.pages ?? []) events.push(pageEntry(page)) if (input.investigation != null) events.push(...investigationEntries(input.investigation)) if (incident.status === "resolved" && incident.resolvedAt != null) { From 27e54c2f38d4acd2ff354f9013c975a638955115 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:47:09 +0100 Subject: [PATCH 45/98] feat(lab): /lab/oncall for schedules, their form and calendar, chain targets and pages --- apps/web/src/lab/entries/oncall.ts | 16 + apps/web/src/lab/oncall-lab.ts | 482 +++++++++++++++++++++++++++++ apps/web/src/lab/registry.ts | 8 + 3 files changed, 506 insertions(+) create mode 100644 apps/web/src/lab/entries/oncall.ts create mode 100644 apps/web/src/lab/oncall-lab.ts diff --git a/apps/web/src/lab/entries/oncall.ts b/apps/web/src/lab/entries/oncall.ts new file mode 100644 index 0000000000..c99d3b3349 --- /dev/null +++ b/apps/web/src/lab/entries/oncall.ts @@ -0,0 +1,16 @@ +import { defineShellLab } from "../shell-lab" +import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../oncall-lab" + +// `/lab/oncall`: on-call schedules, their form and calendar, chain targets and paged events (`?state=`). + +export const lab = defineShellLab("On-call schedules", { + Model, + Message, + Flags, + flags, + init, + update, + view, + currentPath: "/lab/oncall", + breadcrumbs, +}) diff --git a/apps/web/src/lab/oncall-lab.ts b/apps/web/src/lab/oncall-lab.ts new file mode 100644 index 0000000000..172aacc171 --- /dev/null +++ b/apps/web/src/lab/oncall-lab.ts @@ -0,0 +1,482 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import type { LoadFailure } from "@maple/views/data/load-failure" +import { zonedDayStart } from "@maple/views/ui/shift-timeline" +import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" +import { Command, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import * as Menu from "@maple/views/ui/menu" +import * as HubMessage from "../page/alerts/hub/message" +import * as HubModel from "../page/alerts/hub/model" +import * as HubUpdate from "../page/alerts/hub/update" +import { view as hubView } from "../page/alerts/hub/view" +import * as IncidentMessage from "../page/alerts/incident/message" +import * as IncidentModel from "../page/alerts/incident/model" +import * as IncidentUpdate from "../page/alerts/incident/update" +import { breadcrumbsOf as incidentBreadcrumbs, view as incidentView } from "../page/alerts/incident/view" +import * as FormMessage from "../page/alerts/schedule-form/message" +import * as FormModel from "../page/alerts/schedule-form/model" +import * as FormUpdate from "../page/alerts/schedule-form/update" +import { view as formView } from "../page/alerts/schedule-form/view" +import * as ScheduleMessage from "../page/alerts/schedule/message" +import * as ScheduleModel from "../page/alerts/schedule/model" +import * as ScheduleUpdate from "../page/alerts/schedule/update" +import { breadcrumbsOf as scheduleBreadcrumbs, view as scheduleView } from "../page/alerts/schedule/view" +import { chainDestinations, chainUuid } from "../page/alerts/shared/escalation-test-fixtures" +import { + checkoutOverrides, + checkoutSchedule, + checkoutShifts, + DST_NOW, + memberId, + mixedTargetChain, + ONCALL_NOW, + oncallMembers, + pagedEvents, + populatedSchedules, + schedulePublicId, +} from "../page/alerts/shared/oncall-test-fixtures" +import { incidentRow, INCIDENT_ID, ORG, ruleRow } from "../page/alerts/shared/test-rows" +import type { PageContext } from "../page/contract" +import type { Resources } from "../platform/resources" +import { isoMs } from "../page/alerts/shared/time" + +// `/lab/oncall`: on-call schedules in the hub (the On-call tab and the inbox's "On call now" +// strip), the schedule form, the calendar with overrides and a DST week, the chain dialog with +// people and schedules, and an incident timeline with pages. `?state=` picks one. + +export const LabState = Schema.Literals([ + "schedules-empty", + "schedules", + "strip", + "editor-new", + "editor-edit", + "editor-invalid", + "editor-error", + "calendar", + "calendar-dst", + "override-dialog", + "chain-dialog", + "incident-paged", +]) +export type LabState = typeof LabState.Type + +export const Flags = Schema.Struct({ state: LabState }) +export type Flags = typeof Flags.Type + +export const flags: Effect.Effect = Effect.sync(() => ({ + state: Option.getOrElse( + Schema.decodeUnknownOption(LabState)(new URLSearchParams(window.location.search).get("state")), + (): LabState => "schedules", + ), +})) + +export const Model = Schema.Struct({ + state: LabState, + hub: HubModel.Model, + form: FormModel.Model, + schedule: ScheduleModel.Model, + incident: IncidentModel.Model, +}) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + GotHubMessage: { message: HubMessage.Message }, + GotFormMessage: { message: FormMessage.Message }, + GotScheduleMessage: { message: ScheduleMessage.Message }, + GotIncidentMessage: { message: IncidentMessage.Message }, +}) +export type Message = typeof Message.Type + +type Return = Update.Return +type Step = Readonly<{ model: PM; commands?: ReadonlyArray> }> + +/** The schedule is shown in Berlin; the viewer reads UTC, so the calendar notes both. */ +const CONTEXT: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNamespace: null, timeZone: "UTC" } +const GRANTS = Policy.grantsOf(["org:engineer"]) + +/** Reads and writes are dropped: the fixture is all the data there is. */ +const keepLocal = (commands: ReadonlyArray> | undefined) => + Arr.filter( + commands ?? [], + (command) => !/^(Fetch|Toggle|Delete|Save|Read|Create|Generate)/.test(command.name), + ) + +const fold = ( + start: Step, + messages: ReadonlyArray, + step: (model: PM, message: PS) => Step, +): Step => + Arr.reduce(messages, { model: start.model, commands: keepLocal(start.commands) }, (current, message) => { + const next = step(current.model, message) + return { model: next.model, commands: [...(current.commands ?? []), ...keepLocal(next.commands)] } + }) + +const failure = (message: string): LoadFailure => ({ + _tag: "@maple/http/errors/AlertValidationError", + type: "invalid_request_error", + code: "invalid_request", + title: "Invalid request", + message, + retryable: false, + recovery: "fix_request", +}) + +// HUB + +const hubStep = (model: HubModel.Model, message: HubMessage.Message) => + HubUpdate.update(model, message, CONTEXT) + +const hubFor = (state: LabState): Step => { + const tab = state === "strip" ? "inbox" : state === "chain-dialog" ? "routing" : "oncall" + const entered = HubUpdate.init({ _tag: "Alerts", search: { tab } }, CONTEXT).model + const schedules = state === "schedules-empty" ? [] : populatedSchedules(ONCALL_NOW) + const chains = [mixedTargetChain(chainDestinations[0]?.id ?? "")] + return fold( + { model: entered }, + [ + HubMessage.Message.ReadClock({ nowMs: ONCALL_NOW }), + HubMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }), + HubMessage.Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + HubMessage.Message.UpdatedDestinations({ orgId: ORG, rows: chainDestinations }), + HubMessage.Message.UpdatedRules({ + orgId: ORG, + rows: [ruleRow(1, { name: "Checkout error rate" }), ruleRow(2)], + }), + HubMessage.Message.UpdatedRuleStates({ orgId: ORG, rows: [] }), + HubMessage.Message.UpdatedIncidents({ + orgId: ORG, + rows: [incidentRow({ rule_name: "Checkout p95 latency" })], + }), + HubMessage.Message.CompletedFetchSchedules({ + key: entered.schedules.key ?? "", + readAtMs: ONCALL_NOW, + result: Result.succeed(schedules), + }), + HubMessage.Message.CompletedFetchRoutingDeliveries({ + key: entered.routingDeliveries.key ?? "", + result: Result.succeed([]), + }), + HubMessage.Message.CompletedFetchDeliveries({ + key: entered.deliveries.key ?? "", + result: Result.succeed([]), + }), + HubMessage.Message.CompletedFetchChains({ + key: entered.chains.key ?? "", + result: Result.succeed(chains), + }), + ...(state === "chain-dialog" + ? [ + HubMessage.Message.GotRowMenuMessage({ + menuId: `chain:${chainUuid(1)}`, + message: Menu.Message.ClickedItem({ + index: 0, + item: { kind: "Item", value: "edit" }, + }), + }), + ] + : []), + ], + hubStep, + ) +} + +// FORM + +const formStep = (model: FormModel.Model, message: FormMessage.Message) => + FormUpdate.update(model, message, CONTEXT) + +const formFor = (state: LabState): Step => { + const isEdit = state === "editor-edit" || state === "editor-error" + const start = FormUpdate.init( + { _tag: "AlertsScheduleNew", search: isEdit ? { scheduleId: schedulePublicId(1) } : {} }, + CONTEXT, + ) + const base = [ + FormMessage.Message.ReadDefaults({ + timeZone: "Europe/Berlin", + nowMs: ONCALL_NOW, + zones: Intl.supportedValuesOf("timeZone"), + }), + FormMessage.Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + FormMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }), + ...(isEdit + ? [ + FormMessage.Message.CompletedFetchExisting({ + key: start.model.existing.key ?? "", + result: Result.succeed(checkoutSchedule(ONCALL_NOW)), + }), + ] + : []), + ] + const extra: ReadonlyArray = + state === "editor-invalid" + ? [ + FormMessage.Message.ChangedLayerText({ key: "l0", field: "handoffTime", value: "" }), + FormMessage.Message.ClickedAddRestriction({ key: "l0" }), + FormMessage.Message.ToggledRestrictionDay({ + key: "l0", + restrictionKey: "r1", + weekday: "mon", + }), + FormMessage.Message.ToggledRestrictionDay({ + key: "l0", + restrictionKey: "r1", + weekday: "tue", + }), + FormMessage.Message.ToggledRestrictionDay({ + key: "l0", + restrictionKey: "r1", + weekday: "wed", + }), + FormMessage.Message.ToggledRestrictionDay({ + key: "l0", + restrictionKey: "r1", + weekday: "thu", + }), + FormMessage.Message.ToggledRestrictionDay({ + key: "l0", + restrictionKey: "r1", + weekday: "fri", + }), + FormMessage.Message.ClickedSave(), + ] + : state === "editor-error" + ? [ + FormMessage.Message.ClickedSave(), + FormMessage.Message.CompletedSave({ + result: Result.fail( + failure("Omar Haddad is no longer a member of this organization."), + ), + }), + ] + : state === "editor-new" + ? [FormMessage.Message.ChangedName({ value: "Checkout primary" })] + : [] + return fold(start, [...base, ...extra], formStep) +} + +// SCHEDULE PAGE + +const scheduleStep = (model: ScheduleModel.Model, message: ScheduleMessage.Message) => + ScheduleUpdate.update(model, message, CONTEXT) + +const scheduleFor = (state: LabState): Step => { + const nowMs = state === "calendar-dst" ? DST_NOW : ONCALL_NOW + const start = ScheduleUpdate.init( + { _tag: "AlertsSchedule", scheduleId: schedulePublicId(1), search: {} }, + CONTEXT, + ) + const schedule = checkoutSchedule(nowMs) + const loaded = fold( + start, + [ + ScheduleMessage.Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + ScheduleMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }), + ScheduleMessage.Message.ReadClock({ nowMs }), + ScheduleMessage.Message.CompletedFetchSchedule({ + key: start.model.schedule.key ?? "", + result: Result.succeed(schedule), + }), + ScheduleMessage.Message.CompletedFetchOverrides({ + key: start.model.overrides.key ?? "", + result: Result.succeed(checkoutOverrides(nowMs)), + }), + ], + scheduleStep, + ) + const fromMs = zonedDayStart(nowMs, schedule.time_zone) + const toMs = zonedDayStart(nowMs, schedule.time_zone, ScheduleUpdate.CALENDAR_DAYS) + const withShifts = fold( + loaded, + [ + ScheduleMessage.Message.CompletedFetchShifts({ + key: loaded.model.shifts.key ?? "", + result: Result.succeed(checkoutShifts(nowMs, fromMs, toMs)), + }), + ], + scheduleStep, + ) + return state === "override-dialog" + ? fold( + withShifts, + [ + ScheduleMessage.Message.ClickedShift({ + startMs: fromMs + 33 * 3_600_000, + endMs: fromMs + 41 * 3_600_000, + }), + ScheduleMessage.Message.ChangedOverrideUser({ value: memberId(7) }), + ], + scheduleStep, + ) + : withShifts +} + +// INCIDENT + +const incidentStep = (model: IncidentModel.Model, message: IncidentMessage.Message) => + IncidentUpdate.update(model, message, CONTEXT) + +const incidentFor = (): Step => { + const start = IncidentUpdate.init({ _tag: "AlertIncident", incidentId: INCIDENT_ID, search: {} }, CONTEXT) + const row = incidentRow({ rule_name: "Checkout p95 latency" }) + const synced = fold( + start, + [ + IncidentMessage.Message.ReadClock({ nowMs: isoMs(row.first_triggered_at) + 20 * 60_000 }), + IncidentMessage.Message.UpdatedIncidents({ orgId: ORG, rows: [row] }), + IncidentMessage.Message.UpdatedRules({ + orgId: ORG, + rows: [ruleRow(2, { name: "Checkout p95 latency" })], + }), + IncidentMessage.Message.UpdatedRuleStates({ orgId: ORG, rows: [] }), + ], + incidentStep, + ) + return fold( + synced, + [ + IncidentMessage.Message.CompletedFetchEvents({ + key: synced.model.events.key ?? "", + result: Result.succeed(pagedEvents(INCIDENT_ID, isoMs(row.first_triggered_at))), + }), + IncidentMessage.Message.CompletedFetchEscalation({ + key: synced.model.escalation.key ?? "", + result: Result.succeed(null), + }), + IncidentMessage.Message.CompletedFetchDeliveries({ + key: synced.model.deliveries.key ?? "", + result: Result.succeed([]), + }), + IncidentMessage.Message.CompletedFetchInvestigation({ + key: synced.model.investigation.key ?? "", + result: Result.succeed(null), + }), + ], + incidentStep, + ) +} + +const isHub = (state: LabState) => + state === "schedules" || state === "schedules-empty" || state === "strip" || state === "chain-dialog" +const isForm = (state: LabState) => Str.startsWith("editor-")(state) +const isSchedule = (state: LabState) => Str.startsWith("calendar")(state) || state === "override-dialog" + +export const init = (flags: Flags): Return => { + const hub = hubFor(flags.state) + const form = formFor(flags.state) + const schedule = scheduleFor(flags.state) + const incident = incidentFor() + return { + model: { + state: flags.state, + hub: hub.model, + form: form.model, + schedule: schedule.model, + incident: incident.model, + }, + commands: [ + ...(isHub(flags.state) + ? Command.mapMessages(hub.commands ?? [], (message) => Message.GotHubMessage({ message })) + : []), + ...(isForm(flags.state) + ? Command.mapMessages(form.commands ?? [], (message) => Message.GotFormMessage({ message })) + : []), + ...(isSchedule(flags.state) + ? Command.mapMessages(schedule.commands ?? [], (message) => + Message.GotScheduleMessage({ message }), + ) + : []), + ...(flags.state === "incident-paged" + ? Command.mapMessages(incident.commands ?? [], (message) => + Message.GotIncidentMessage({ message }), + ) + : []), + ], + } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotHubMessage: ({ message: child }) => { + const next = HubUpdate.update(model.hub, child, CONTEXT) + return { + model: { ...model, hub: next.model }, + commands: Command.mapMessages(keepLocal(next.commands), (inner) => + Message.GotHubMessage({ message: inner }), + ), + } + }, + GotFormMessage: ({ message: child }) => { + const next = FormUpdate.update(model.form, child, CONTEXT) + return { + model: { ...model, form: next.model }, + commands: Command.mapMessages(keepLocal(next.commands), (inner) => + Message.GotFormMessage({ message: inner }), + ), + } + }, + GotScheduleMessage: ({ message: child }) => { + const next = ScheduleUpdate.update(model.schedule, child, CONTEXT) + return { + model: { ...model, schedule: next.model }, + commands: Command.mapMessages(keepLocal(next.commands), (inner) => + Message.GotScheduleMessage({ message: inner }), + ), + } + }, + GotIncidentMessage: ({ message: child }) => { + const next = IncidentUpdate.update(model.incident, child, CONTEXT) + return { + model: { ...model, incident: next.model }, + commands: Command.mapMessages(keepLocal(next.commands), (inner) => + Message.GotIncidentMessage({ message: inner }), + ), + } + }, + }) + +export const breadcrumbs = (model: Model) => + isHub(model.state) + ? [{ label: "Alerts" }] + : isForm(model.state) + ? [{ label: "Alerts", href: "/alerts" }, { label: "On-call" }] + : isSchedule(model.state) + ? scheduleBreadcrumbs() + : incidentBreadcrumbs(model.incident) + +export const view = (model: Model, h: HtmlBuilder): Html => + isHub(model.state) + ? h.submodel({ + slotId: "lab-oncall-hub", + model: model.hub, + view: hubView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: HubMessage.Message) => Message.GotHubMessage({ message }), + }) + : isForm(model.state) + ? h.submodel({ + slotId: "lab-oncall-form", + model: model.form, + view: formView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: FormMessage.Message) => Message.GotFormMessage({ message }), + }) + : isSchedule(model.state) + ? h.submodel({ + slotId: "lab-oncall-schedule", + model: model.schedule, + view: scheduleView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: ScheduleMessage.Message) => + Message.GotScheduleMessage({ message }), + }) + : h.submodel({ + slotId: "lab-oncall-incident", + model: model.incident, + view: incidentView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: IncidentMessage.Message) => + Message.GotIncidentMessage({ message }), + }) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index e5c5d24c0e..89deb0782e 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -69,6 +69,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/oncall", + title: "On-call schedules", + description: + "The On-call tab empty and populated, the inbox's who-is-on-call strip, the schedule form new, editing, invalid and failing, the week calendar with overrides and a DST week, the override dialog, the chain dialog with people and schedules, and an incident timeline with pages (`?state=`).", + kind: "lab", + session: "none", + }, { path: "/lab/billing-orgs", title: "Billing orgs", From 5483d7c52bead34d10f49e332e777dec4c05888a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:48:33 +0100 Subject: [PATCH 46/98] fix(alerts): the escalation chain dialog locks its form while a save is in flight Edits typed during the request were applied to the model and then discarded when the save closed the dialog. The fields, step controls and destination picker now render disabled while saving, and update drops those edits. Field input and the destination picker gain an isDisabled option. --- .../escalation-dialog/dialog.scene.test.ts | 50 ++++++++++++++++ .../alerts/escalation-dialog/dialog.test.ts | 16 +++++ .../page/alerts/escalation-dialog/dialog.ts | 59 +++++++++++-------- .../src/page/alerts/escalation-dialog/view.ts | 17 ++++-- apps/web/src/page/kit/destination-picker.ts | 5 ++ packages/views/src/ui/field.ts | 3 + 6 files changed, 123 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts new file mode 100644 index 0000000000..3ed3f08821 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts @@ -0,0 +1,50 @@ +import { expect as expectView, given, Mount, role, scene, withViewInputs } from "foldkit/scene" +import { describe, test } from "vitest" +import * as Dialog from "@maple/views/ui/dialog" +import { MountDialog } from "@maple/views/ui/dialog" +import { chainDestinations, chainOf } from "../shared/escalation-test-fixtures" +import { init, Message, openEdit, update } from "./dialog" +import { view as dialogView } from "./view" + +const view = withViewInputs(dialogView, { id: "chain", destinations: chainDestinations })() +const editing = () => + openEdit( + init("chain"), + chainOf({ + n: 1, + name: "Checkout on-call", + steps: [ + { destinations: [1], wait: 10 }, + { destinations: [2], wait: 15 }, + ], + repeat: 1, + }), + ).model + +describe("escalation chain dialog view", () => { + test("while a save is in flight the fields and step controls are disabled", () => { + scene( + { update, view }, + given(update(editing(), Message.ClickedSave()).model), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(role("textbox", { name: "Name" })).toBeDisabled(), + expectView(role("textbox", { name: "Description" })).toBeDisabled(), + expectView(role("spinbutton", { name: "Repeat the whole chain" })).toBeDisabled(), + expectView(role("button", { name: "Move step 1 down" })).toBeDisabled(), + expectView(role("button", { name: "Remove step 1" })).toBeDisabled(), + expectView(role("button", { name: "Add step" })).toBeDisabled(), + expectView(role("button", { name: /^Stop notifying/ })).toBeDisabled(), + ) + }) + + test("before a save the same controls are editable", () => { + scene( + { update, view }, + given(editing()), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(role("textbox", { name: "Name" })).not.toBeDisabled(), + expectView(role("spinbutton", { name: "Repeat the whole chain" })).not.toBeDisabled(), + expectView(role("button", { name: "Add step" })).not.toBeDisabled(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts index a3acf562a1..ff9cdd0598 100644 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts +++ b/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts @@ -69,6 +69,22 @@ describe("escalation chain dialog", () => { expect(update(saving.model, Message.ClickedSave()).commands ?? []).toEqual([]) }) + it("edits sent while a save is in flight leave the form as it was sent", () => { + const saving = update(opened(), Message.ClickedSave()).model + const key = saving.steps[0]?.key ?? "" + const edited = run(saving, [ + Message.ChangedName({ value: "Renamed mid-save" }), + Message.ChangedDescription({ value: "typed while saving" }), + Message.ChangedWait({ key, value: "30" }), + Message.ChangedRepeatCount({ value: "3" }), + Message.ClickedAddStep(), + Message.ClickedRemoveStep({ key }), + Message.ClickedMoveStep({ key, direction: "down" }), + Message.ClickedRemoveDestination({ key, id: asDestination(chainDestinationUuid(1)) }), + ]) + expect(edited).toEqual(saving) + }) + it("steps reorder up and down, add up to ten, and the last one cannot move further", () => { const model = opened() const [first, second] = model.steps diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.ts index 9dc89994eb..317c8fe6b7 100644 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.ts +++ b/apps/web/src/page/alerts/escalation-dialog/dialog.ts @@ -293,7 +293,8 @@ const stepMenu = (model: Model, key: string, message: Menu.Message): Return => { result.outMessage?._tag === "SelectedItem" ? Option.some(result.outMessage.value) : Option.none(), decodeDestinationId, ) - return Option.match(picked, { + // The menu still opens and closes while saving, but a pick no longer edits the step. + return Option.match(model.isSaving ? Option.none() : picked, { onNone: () => ({ model: next, commands }), onSome: (destinationId) => ({ ...editStep(next, key, (step) => @@ -323,37 +324,49 @@ const save = (model: Model): Return => }), }) +/** A form edit is dropped while a save is in flight: the saved chain is what the dialog shows. */ +const unlessSaving = (model: Model, edit: () => Return): Return => (model.isSaving ? { model } : edit()) + export const update = (model: Model, message: Message): Return => Message.match(message, { GotDialogMessage: ({ message: child }) => // The dialog stays open while a save is in flight. model.isSaving ? { model } : liftDialog(model, Dialog.update(model.dialog, child)), - ChangedName: ({ value }) => ({ model: { ...model, name: value } }), - ChangedDescription: ({ value }) => ({ model: { ...model, description: value } }), - ChangedRepeatCount: ({ value }) => ({ model: { ...model, repeatCount: value } }), - ChangedWait: ({ key, value }) => editStep(model, key, (step) => ({ ...step, waitMinutes: value })), + ChangedName: ({ value }) => unlessSaving(model, () => ({ model: { ...model, name: value } })), + ChangedDescription: ({ value }) => + unlessSaving(model, () => ({ model: { ...model, description: value } })), + ChangedRepeatCount: ({ value }) => + unlessSaving(model, () => ({ model: { ...model, repeatCount: value } })), + ChangedWait: ({ key, value }) => + unlessSaving(model, () => editStep(model, key, (step) => ({ ...step, waitMinutes: value }))), ClickedAddStep: () => - model.steps.length >= MAX_ESCALATION_STEPS - ? { model } - : { - model: { - ...model, - steps: [...model.steps, emptyStep(model.nextKey)], - nextKey: model.nextKey + 1, + unlessSaving(model, () => + model.steps.length >= MAX_ESCALATION_STEPS + ? { model } + : { + model: { + ...model, + steps: [...model.steps, emptyStep(model.nextKey)], + nextKey: model.nextKey + 1, + }, }, - }, - ClickedRemoveStep: ({ key }) => ({ - model: { ...model, steps: Arr.filter(model.steps, (step) => step.key !== key) }, - }), - ClickedMoveStep: ({ key, direction }) => ({ - model: { ...model, steps: moveStep(model.steps, key, direction) }, - }), + ), + ClickedRemoveStep: ({ key }) => + unlessSaving(model, () => ({ + model: { ...model, steps: Arr.filter(model.steps, (step) => step.key !== key) }, + })), + ClickedMoveStep: ({ key, direction }) => + unlessSaving(model, () => ({ + model: { ...model, steps: moveStep(model.steps, key, direction) }, + })), GotStepMenuMessage: ({ key, message: child }) => stepMenu(model, key, child), ClickedRemoveDestination: ({ key, id }) => - editStep(model, key, (step) => ({ - ...step, - destinationIds: Arr.filter(step.destinationIds, (existing) => existing !== id), - })), + unlessSaving(model, () => + editStep(model, key, (step) => ({ + ...step, + destinationIds: Arr.filter(step.destinationIds, (existing) => existing !== id), + })), + ), ClickedSave: () => save(model), CompletedSave: ({ openCount, result }) => openCount !== model.openCount diff --git a/apps/web/src/page/alerts/escalation-dialog/view.ts b/apps/web/src/page/alerts/escalation-dialog/view.ts index 8c5f7db4df..0c3043f65e 100644 --- a/apps/web/src/page/alerts/escalation-dialog/view.ts +++ b/apps/web/src/page/alerts/escalation-dialog/view.ts @@ -84,6 +84,7 @@ const textField = ( value: string placeholder: string error: Option.Option + isDisabled: boolean onInput: (value: string) => Message }>, ): Html => { @@ -93,6 +94,7 @@ const textField = ( input(h, { ids: options.ids, state, + isDisabled: options.isDisabled, attributes: [ h.Value(options.value), h.Placeholder(options.placeholder), @@ -115,6 +117,7 @@ const numberField = ( max: number description?: string error: Option.Option + isDisabled: boolean onInput: (value: string) => Message }>, ): Html => { @@ -126,6 +129,7 @@ const numberField = ( ids: options.ids, state, className: "w-20", + isDisabled: options.isDisabled, attributes: [ h.Type("number"), h.Min(String(options.min)), @@ -197,7 +201,7 @@ const stepBlock = (h: H, model: Model, inputs: ViewInputs, step: StepDraft, inde h, { label: `Move step ${index + 1} up`, - isDisabled: index === 0, + isDisabled: model.isSaving || index === 0, attributes: [h.OnClick(Message.ClickedMoveStep({ key, direction: "up" }))], }, [icon(h, "ArrowUpIcon")], @@ -206,7 +210,7 @@ const stepBlock = (h: H, model: Model, inputs: ViewInputs, step: StepDraft, inde h, { label: `Move step ${index + 1} down`, - isDisabled: index === count - 1, + isDisabled: model.isSaving || index === count - 1, attributes: [h.OnClick(Message.ClickedMoveStep({ key, direction: "down" }))], }, [icon(h, "ArrowDownIcon")], @@ -215,7 +219,7 @@ const stepBlock = (h: H, model: Model, inputs: ViewInputs, step: StepDraft, inde h, { label: `Remove step ${index + 1}`, - isDisabled: count === 1, + isDisabled: model.isSaving || count === 1, attributes: [h.OnClick(Message.ClickedRemoveStep({ key }))], }, [icon(h, "TrashIcon")], @@ -228,6 +232,7 @@ const stepBlock = (h: H, model: Model, inputs: ViewInputs, step: StepDraft, inde menu: model.menus[key] ?? Menu.init(menuIdOf(model, key)), toMenuMessage: (message) => Message.GotStepMenuMessage({ key, message }), onRemove: (id) => Message.ClickedRemoveDestination({ key, id }), + isDisabled: model.isSaving, emptyText: inputs.destinations !== null && inputs.destinations.length === 0 ? "No destinations exist yet. Add one in Routing first." @@ -245,6 +250,7 @@ const stepBlock = (h: H, model: Model, inputs: ViewInputs, step: StepDraft, inde min: 1, max: 1440, error: shownError(model, wait, isOutOfRange(step.waitMinutes, wait)), + isDisabled: model.isSaving, onInput: (value) => Message.ChangedWait({ key, value }), }), ]) @@ -269,7 +275,7 @@ const stepsSection = (h: H, model: Model, inputs: ViewInputs): Html => variant: "outline", size: "sm", className: "self-start", - isDisabled: model.steps.length >= MAX_ESCALATION_STEPS, + isDisabled: model.isSaving || model.steps.length >= MAX_ESCALATION_STEPS, attributes: [h.OnClick(Message.ClickedAddStep())], }, [icon(h, "PlusIcon"), "Add step"], @@ -285,6 +291,7 @@ const descriptionField = (h: H, model: Model): Html => { textarea(h, { id: ids.control, defaultValue: model.description, + isDisabled: model.isSaving, attributes: [ h.AriaLabelledBy(ids.label), h.Placeholder("Optional: who is on this chain and when to change it"), @@ -316,6 +323,7 @@ export const view = Submodel.defineView((model, inpu value: model.name, placeholder: "Checkout on-call", error: shownError(model, nameError(model.name)), + isDisabled: model.isSaving, onInput: (value) => Message.ChangedName({ value }), }), descriptionField(h, model), @@ -328,6 +336,7 @@ export const view = Submodel.defineView((model, inpu max: 5, description: "Extra passes after the first, 0 to 5. 0 runs it once.", error: shownError(model, repeat, isOutOfRange(model.repeatCount, repeat)), + isDisabled: model.isSaving, onInput: (value) => Message.ChangedRepeatCount({ value }), }), ...(model.saveError === null diff --git a/apps/web/src/page/kit/destination-picker.ts b/apps/web/src/page/kit/destination-picker.ts index 51e67080f3..8c732e5ec3 100644 --- a/apps/web/src/page/kit/destination-picker.ts +++ b/apps/web/src/page/kit/destination-picker.ts @@ -25,9 +25,12 @@ export const destinationPicker = ( onRemove: (id: Id) => M /** What an empty pick means, in one line. */ emptyText: string + /** The pick is shown but cannot change (a form whose save is in flight). */ + isDisabled?: boolean }>, ): Html => { const rows = options.rows ?? [] + const isDisabled = options.isDisabled ?? false // `null` until destinations sync: a neutral name, not "unavailable", for the saved ones. const fallback = options.rows === null ? "Destination" : "Unavailable destination" const selected = Arr.map(options.selectedIds, (id) => ({ @@ -53,6 +56,7 @@ export const destinationPicker = ( { label: `Stop notifying ${destination.name}`, className: "text-muted-foreground", + isDisabled, attributes: [h.OnClick(options.onRemove(destination.id))], }, [icon(h, "XmarkIcon")], @@ -90,6 +94,7 @@ export const destinationPicker = ( { className: "flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-muted-foreground hover:bg-surface-sunken active:bg-surface-sunken hover:text-foreground active:text-foreground", + isDisabled, attributes: trigger, }, [icon(h, "PlusIcon", { size: 14 }), "Add destination"], diff --git a/packages/views/src/ui/field.ts b/packages/views/src/ui/field.ts index 4858aef33b..b60ade9e1e 100644 --- a/packages/views/src/ui/field.ts +++ b/packages/views/src/ui/field.ts @@ -156,6 +156,8 @@ export const input = ( attributes: ReadonlyArray> /** ``: on the control wrapper, as @maple/ui puts it. */ className?: string + /** Base UI's `disabled`: shown, not editable (a form whose save is in flight). */ + isDisabled?: boolean }>, ): Html => { const described = describedBy(options.ids, options.state) @@ -163,6 +165,7 @@ export const input = ( id: options.ids.control, fieldState: options.state, className: options.className, + isDisabled: options.isDisabled, attributes: [ ...options.attributes, h.AriaLabelledBy(options.ids.label), From 849d6a56de539c3c095367a114a2357af5109e4b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:49:17 +0100 Subject: [PATCH 47/98] fix(alerts): the escalation dialog's wait and repeat ranges come from the API limits The inputs' max and the repeat copy were literals; they now derive from MAX_ESCALATION_WAIT_MINUTES and MAX_ESCALATION_REPEATS, as the step count does. --- .../escalation-dialog/dialog.scene.test.ts | 20 +++++++++++++++++++ .../src/page/alerts/escalation-dialog/view.ts | 8 ++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts index 3ed3f08821..047913f245 100644 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts +++ b/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts @@ -1,3 +1,4 @@ +import { MAX_ESCALATION_REPEATS, MAX_ESCALATION_WAIT_MINUTES } from "@maple/domain/http" import { expect as expectView, given, Mount, role, scene, withViewInputs } from "foldkit/scene" import { describe, test } from "vitest" import * as Dialog from "@maple/views/ui/dialog" @@ -47,4 +48,23 @@ describe("escalation chain dialog view", () => { expectView(role("button", { name: "Add step" })).not.toBeDisabled(), ) }) + + test("the wait and repeat inputs take their range and copy from the API's limits", () => { + scene( + { update, view }, + given(editing()), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(role("spinbutton", { name: "Repeat the whole chain" })).toHaveAttr( + "max", + String(MAX_ESCALATION_REPEATS), + ), + expectView(role("spinbutton", { name: "Repeat the whole chain" })).toHaveAccessibleDescription( + `Extra passes after the first, 0 to ${MAX_ESCALATION_REPEATS}. 0 runs it once.`, + ), + expectView(role("spinbutton", { name: /^Then wait/ })).toHaveAttr( + "max", + String(MAX_ESCALATION_WAIT_MINUTES), + ), + ) + }) }) diff --git a/apps/web/src/page/alerts/escalation-dialog/view.ts b/apps/web/src/page/alerts/escalation-dialog/view.ts index 0c3043f65e..1691a2f13e 100644 --- a/apps/web/src/page/alerts/escalation-dialog/view.ts +++ b/apps/web/src/page/alerts/escalation-dialog/view.ts @@ -1,4 +1,4 @@ -import { MAX_ESCALATION_STEPS } from "@maple/domain/http" +import { MAX_ESCALATION_REPEATS, MAX_ESCALATION_STEPS, MAX_ESCALATION_WAIT_MINUTES } from "@maple/domain/http" import type { AlertDestinationRow } from "@maple/web-data/collections/alerts" import { Option, Array as Arr } from "effect" import { Submodel } from "foldkit" @@ -248,7 +248,7 @@ const stepBlock = (h: H, model: Model, inputs: ViewInputs, step: StepDraft, inde label: waitLabel(model, index), value: step.waitMinutes, min: 1, - max: 1440, + max: MAX_ESCALATION_WAIT_MINUTES, error: shownError(model, wait, isOutOfRange(step.waitMinutes, wait)), isDisabled: model.isSaving, onInput: (value) => Message.ChangedWait({ key, value }), @@ -333,8 +333,8 @@ export const view = Submodel.defineView((model, inpu label: "Repeat the whole chain", value: model.repeatCount, min: 0, - max: 5, - description: "Extra passes after the first, 0 to 5. 0 runs it once.", + max: MAX_ESCALATION_REPEATS, + description: `Extra passes after the first, 0 to ${MAX_ESCALATION_REPEATS}. 0 runs it once.`, error: shownError(model, repeat, isOutOfRange(model.repeatCount, repeat)), isDisabled: model.isSaving, onInput: (value) => Message.ChangedRepeatCount({ value }), From 18b5f850a56aff9fe196204bbc1cb95f8b9cdf77 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:49:18 +0100 Subject: [PATCH 48/98] fix(lab): the escalation lab keeps an allowlist of local commands The name-prefix denylist let writes such as SetAlertIncidentAcknowledged and CreateAlertInvestigation reach the real API with fixture ids. Only the kit's UI commands and the chart runtime run now. --- apps/web/src/lab/escalation-lab.test.ts | 21 ++++++++++++++++++++ apps/web/src/lab/escalation-lab.ts | 26 +++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lab/escalation-lab.test.ts diff --git a/apps/web/src/lab/escalation-lab.test.ts b/apps/web/src/lab/escalation-lab.test.ts new file mode 100644 index 0000000000..6cde280598 --- /dev/null +++ b/apps/web/src/lab/escalation-lab.test.ts @@ -0,0 +1,21 @@ +import { Array as Arr } from "effect" +import { describe, expect, it } from "vitest" +import * as IncidentMessage from "../page/alerts/incident/message" +import { init, Message, update } from "./escalation-lab" + +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) + +describe("escalation lab", () => { + it("drops page writes so fixture ids never reach the API", () => { + const { model } = init({ state: "incident-escalating" }) + const incident = (message: IncidentMessage.Message) => + names(update(model, Message.GotIncidentMessage({ message })).commands) + expect(incident(IncidentMessage.Message.ClickedAcknowledge({ acknowledged: true }))).toEqual([]) + expect(incident(IncidentMessage.Message.ClickedInvestigate())).toEqual([]) + }) + + it("keeps the chart runtime the incident rail needs", () => { + expect(names(init({ state: "incident-escalating" }).commands)).toContain("LoadChartRuntime") + }) +}) diff --git a/apps/web/src/lab/escalation-lab.ts b/apps/web/src/lab/escalation-lab.ts index 56d80d9222..6c9ed3de41 100644 --- a/apps/web/src/lab/escalation-lab.ts +++ b/apps/web/src/lab/escalation-lab.ts @@ -5,8 +5,13 @@ import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "eff import { Command, type Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" +import * as Animation from "@foldkit/ui/animation" +import { LoadChartRuntime } from "@maple/views/charts/chart-runtime" +import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" +import * as Popover from "@maple/views/ui/popover" import * as Select from "@maple/views/ui/select" +import * as Tooltip from "@maple/views/ui/tooltip" import * as ChainDialog from "../page/alerts/escalation-dialog/dialog" import * as EditorMessage from "../page/alerts/editor/message" import * as EditorModel from "../page/alerts/editor/model" @@ -85,9 +90,26 @@ const RULES = [ ruleRow(3, { name: "Payments webhook failures" }), ] -/** The pages' reads and writes are dropped: the fixture is all the data there is. */ +/** The kit's own UI commands and the chart runtime; every page read and write is dropped. */ +const LOCAL_COMMANDS: ReadonlySet = new Set([ + LoadChartRuntime.name, + Animation.WaitForPaint.name, + Animation.WaitForAnimationSettled.name, + Dialog.WaitForDialogTransition.name, + Menu.FocusMenuItem.name, + Menu.WaitSubmenuDelay.name, + Menu.WaitForMenuExit.name, + Select.FocusSelectItem.name, + Select.WaitForSelectExit.name, + Popover.WaitHoverDelay.name, + Popover.WaitForPopoverExit.name, + Tooltip.WaitOpenDelay.name, + Tooltip.CheckFocusVisible.name, +]) + +/** An allowlist, not a denylist: a new API command stays off the real API with fixture ids. */ const keepLocal = (commands: ReadonlyArray> | undefined) => - Arr.filter(commands ?? [], (command) => !/^(Fetch|Toggle|Delete|Save|Read|Generate)/.test(command.name)) + Arr.filter(commands ?? [], (command) => LOCAL_COMMANDS.has(command.name)) /** Runs messages through a page's update, keeping the local commands (a dialog's open animation). */ const fold = ( From 3c3f45ffba33ca6d065a2512d75a137ddcc4a3e9 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:49:32 +0100 Subject: [PATCH 49/98] fix(alerts): stale escalation steps are dropped from chat digests too; update_alert_rule can set or detach one tier's chain --- apps/ai/src/mcp/lib/escalation-chains.ts | 2 +- apps/ai/src/mcp/tool-descriptors.generated.ts | 4 +- .../mcp/tools/__tests__/alert-tools.test.ts | 23 ++++ .../src/mcp/tools/delete-escalation-chain.ts | 2 +- apps/ai/src/mcp/tools/update-alert-rule.ts | 50 ++++++-- .../src/services/alerts/AlertsService.ts | 113 +++++++++++++----- 6 files changed, 149 insertions(+), 45 deletions(-) diff --git a/apps/ai/src/mcp/lib/escalation-chains.ts b/apps/ai/src/mcp/lib/escalation-chains.ts index 6e5ab5157a..bb4067e608 100644 --- a/apps/ai/src/mcp/lib/escalation-chains.ts +++ b/apps/ai/src/mcp/lib/escalation-chains.ts @@ -165,7 +165,7 @@ export const deleteChainErrors = { message: `Escalation chain is still used by ${Arr.join( Arr.map(error.ruleIds, (id, index) => `"${error.ruleNames[index] ?? id}" (${id})`), ", ", - )}. Detach it first with update_alert_rule escalation_chain_id="none"; a tier that pages only through the chain needs a destination first (destination_ids does that on a one-tier rule; a two-tier rule's critical tier gains one by replacing the rule's thresholds in the v2 API or the dashboard).`, + )}. Detach it first with update_alert_rule escalation_chain_id="none" and escalation_chain_tier set to the tier that names it, so another tier's chain stays. A tier that pages only through the chain needs a destination first: add_destination_ids adds to a one-tier rule or a two-tier rule's warning tier; a critical tier gains one by replacing the rule's thresholds in the v2 API or the dashboard.`, parameter: "chain_id", }), ), diff --git a/apps/ai/src/mcp/tool-descriptors.generated.ts b/apps/ai/src/mcp/tool-descriptors.generated.ts index 2b66db4741..4424830019 100644 --- a/apps/ai/src/mcp/tool-descriptors.generated.ts +++ b/apps/ai/src/mcp/tool-descriptors.generated.ts @@ -35,13 +35,13 @@ export const MCP_TOOL_DESCRIPTORS: ReadonlyArray = [ {"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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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)."},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of destination_ids, paging step by step until acknowledged (from list_escalation_chains): its UUID or its REST id (esc_\u2026)"},"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"}]},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"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"},"acknowledged":{"type":"boolean"},"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). escalation_chain_id adds step-by-step paging on top.","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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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."},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of destination_ids, paging step by step until acknowledged (from list_escalation_chains): its UUID or its REST id (esc_\u2026)"}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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}}"},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of the destinations (from list_escalation_chains), or \"none\" to detach it from every tier. On a rule with warning and critical tiers an id sets the critical tier: its UUID or its REST id (esc_\u2026)"}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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":"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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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}}"},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of the destinations (from list_escalation_chains), or \"none\" to detach. Applies to escalation_chain_tier; without it, an id sets the critical (or only) tier and \"none\" detaches every tier: its UUID or its REST id (esc_\u2026)"},"escalation_chain_tier":{"type":"string","enum":["warning","critical"],"description":"The tier escalation_chain_id sets or detaches, leaving the other tier's chain as it is"}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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":"acknowledge_alert_incident","title":"Acknowledge Alert Incident","description":"Mark an open alert incident as acknowledged (someone is on it) or, with acknowledged=false, clear the acknowledgement. While acknowledged, reminders pause; an escalation to a higher severity still notifies and clears it. Only open incidents; ids from list_alert_incidents.","inputSchema":{"type":"object","properties":{"incident_id":{"type":"string","description":"Alert incident ID: its UUID or its REST id (inc_\u2026)"},"acknowledged":{"type":"boolean","description":"false to unacknowledge. Default true."}},"required":["incident_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"ruleId":{"type":"string"},"ruleName":{"type":"string"},"status":{"type":"string"},"acknowledged":{"type":"boolean"},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"anyOf":[{"type":"string"},{"type":"null"}]},"changed":{"type":"boolean"}},"required":["id","ruleId","ruleName","status","acknowledged","acknowledgedAt","acknowledgedBy","changed"],"additionalProperties":true},"annotations":{"title":"Acknowledge Alert Incident","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"list_escalation_chains","title":"List Escalation Chains","description":"List escalation chains: ordered paging steps that notify destinations one step at a time until an incident is acknowledged or resolved. Shows each chain's steps, repeats and the rules using it. Attach one to a rule with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chains":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["chains","total"],"additionalProperties":true},"annotations":{"title":"List Escalation Chains","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"create_escalation_chain","title":"Create Escalation Chain","description":"Create an escalation chain: steps that each notify their destinations, then wait for an acknowledgement before the next step. A rule tier that names the chain still notifies its own destinations at once; the chain pages on top until the incident is acknowledged or resolved. Attach it with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"Chain name, e.g. 'Checkout on-call'"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["destination_ids","wait_minutes"],"additionalProperties":false}},"description":"Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":10},{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. At most 10 steps of 10 destinations"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5 (default 0)"},"description":{"type":"string","description":"Free-form notes"}},"required":["name","steps"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Create Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, {"name":"update_escalation_chain","title":"Update Escalation Chain","description":"Change an escalation chain's name, steps, repeats or description. Pass only what changes; steps replaces every step. Escalations already running finish on the steps they started with. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to change: its UUID or its REST id (esc_\u2026)"},"name":{"type":"string","description":"New name"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["destination_ids","wait_minutes"],"additionalProperties":false}},"description":"Replaces every step. Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":10},{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. At most 10 steps of 10 destinations"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5"},"description":{"type":"string","description":"New notes"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Update Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, - {"name":"delete_escalation_chain","title":"Delete Escalation Chain","description":"Delete an escalation chain. Refused while a rule tier still names it: detach it with update_alert_rule escalation_chain_id=\"none\" first; a tier that pages only through the chain needs a destination before that (destination_ids on a one-tier rule; the rule's thresholds via the v2 API or dashboard on a two-tier rule). Escalations already running finish. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to delete: its UUID or its REST id (esc_\u2026)"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"additionalProperties":true},"annotations":{"title":"Delete Escalation Chain","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}}, + {"name":"delete_escalation_chain","title":"Delete Escalation Chain","description":"Delete an escalation chain. Refused while a rule tier still names it: detach it first with update_alert_rule escalation_chain_id=\"none\" and escalation_chain_tier naming the tier, so another tier's chain stays; a tier that pages only through the chain needs a destination before that (add_destination_ids for a one-tier rule or the warning tier; a critical tier via the rule's thresholds in the v2 API or dashboard). Escalations already running finish. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to delete: its UUID or its REST id (esc_\u2026)"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"additionalProperties":true},"annotations":{"title":"Delete Escalation Chain","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"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"}]},"spaceAggregation":{"anyOf":[{"type":"string","enum":["sum","avg","min","max","count"]},{"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-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts index 9174752908..7409173afd 100644 --- a/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts @@ -1093,6 +1093,29 @@ describe("alert tools", () => { }) expect(chainOf(tiered, { escalation_chain_id: CHAIN_ID })).toEqual([undefined, CHAIN_ID]) + // A named tier changes only that tier: detaching the warning chain keeps the critical one. + const bothChained = Schema.decodeSync(AlertRuleDocument)({ + ...tiered, + thresholds: Arr.map(tiered.thresholds, (tier) => ({ ...tier, escalationChainId: CHAIN_ID })), + }) + expect( + chainOf(bothChained, { escalation_chain_id: "none", escalation_chain_tier: "warning" }), + ).toEqual([null, CHAIN_ID]) + expect(chainOf(tiered, { escalation_chain_id: CHAIN_ID, escalation_chain_tier: "warning" })).toEqual([ + CHAIN_ID, + undefined, + ]) + const tierAlone = Effect.runSync( + Effect.flip(buildUpdatedRequest(tiered, { escalation_chain_tier: "warning" })), + ) + expect(tierAlone.parameter).toBe("escalation_chain_tier") + const missingTier = Effect.runSync( + Effect.flip( + buildUpdatedRequest(rule, { escalation_chain_id: "none", escalation_chain_tier: "warning" }), + ), + ) + expect(missingTier.parameter).toBe("escalation_chain_tier") + const malformed = Effect.runSync( Effect.flip(buildUpdatedRequest(rule, { escalation_chain_id: "not-a-chain" })), ) diff --git a/apps/ai/src/mcp/tools/delete-escalation-chain.ts b/apps/ai/src/mcp/tools/delete-escalation-chain.ts index 4fa933cd06..83ab8f47d8 100644 --- a/apps/ai/src/mcp/tools/delete-escalation-chain.ts +++ b/apps/ai/src/mcp/tools/delete-escalation-chain.ts @@ -12,7 +12,7 @@ export function registerDeleteEscalationChainTool(server: McpToolRegistrar) { name: "delete_escalation_chain", title: "Delete Escalation Chain", description: - 'Delete an escalation chain. Refused while a rule tier still names it: detach it with update_alert_rule escalation_chain_id="none" first; a tier that pages only through the chain needs a destination before that (destination_ids on a one-tier rule; the rule\'s thresholds via the v2 API or dashboard on a two-tier rule). ' + + "Delete an escalation chain. Refused while a rule tier still names it: detach it first with update_alert_rule escalation_chain_id=\"none\" and escalation_chain_tier naming the tier, so another tier's chain stays; a tier that pages only through the chain needs a destination before that (add_destination_ids for a one-tier rule or the warning tier; a critical tier via the rule's thresholds in the v2 API or dashboard). " + "Escalations already running finish. Ids from list_escalation_chains.", parameters: Schema.Struct({ chain_id: P.resourceId("escalationChain", "Escalation chain to delete"), diff --git a/apps/ai/src/mcp/tools/update-alert-rule.ts b/apps/ai/src/mcp/tools/update-alert-rule.ts index 62752b9e28..25db0e2240 100644 --- a/apps/ai/src/mcp/tools/update-alert-rule.ts +++ b/apps/ai/src/mcp/tools/update-alert-rule.ts @@ -105,7 +105,11 @@ export const UpdateAlertRuleParameters = Schema.Struct({ ), escalation_chain_id: P.optionalResourceId( "escalationChain", - 'Escalation chain an incident starts on top of the destinations (from list_escalation_chains), or "none" to detach it from every tier. On a rule with warning and critical tiers an id sets the critical tier', + 'Escalation chain an incident starts on top of the destinations (from list_escalation_chains), or "none" to detach. Applies to escalation_chain_tier; without it, an id sets the critical (or only) tier and "none" detaches every tier', + ), + escalation_chain_tier: P.optionalOneOf( + ["warning", "critical"], + "The tier escalation_chain_id sets or detaches, leaving the other tier's chain as it is", ), }) @@ -144,7 +148,7 @@ export const buildUpdatedRequest = Effect.fnUntraced(function* ( "escalation_chain_id", )(params.escalation_chain_id) const merged = yield* mergeThresholds(current, params, comparator) - const thresholds = withEscalationChain(merged, chain) + const thresholds = yield* withEscalationChain(current.name, merged, chain, params.escalation_chain_tier) return { name: params.name ?? current.name, @@ -241,16 +245,46 @@ const mergeThresholds = ( } /** Sets the chain on the only tier, or on the critical one of two; `undefined` keeps every tier's. */ +/** + * Sets or detaches a tier's chain. A named tier changes only that tier; without one, an id sets the + * critical (or only) tier and "none" detaches every tier. + */ const withEscalationChain = ( + ruleName: string, tiers: Arr.NonEmptyReadonlyArray, chain: EscalationChainId | null | undefined, -): Arr.NonEmptyReadonlyArray => { - if (chain === undefined) return tiers - // "none" detaches every tier's chain, wherever it was set. - if (chain === null) return Arr.map(tiers, (tier) => ({ ...tier, escalationChainId: null })) - const critical = Arr.findLastIndex(tiers, (tier) => tier.severity === "critical") + tier: AlertThreshold["severity"] | undefined, +): Effect.Effect, McpInvalidInputError> => { + if (chain === undefined) { + return tier === undefined + ? Effect.succeed(tiers) + : Effect.fail( + new McpInvalidInputError({ + message: + 'escalation_chain_tier needs escalation_chain_id (an id, or "none" to detach)', + parameter: "escalation_chain_tier", + }), + ) + } + if (tier !== undefined) { + if (!Arr.some(tiers, (t) => t.severity === tier)) { + return Effect.fail( + new McpInvalidInputError({ + message: `"${ruleName}" has no ${tier} tier`, + parameter: "escalation_chain_tier", + }), + ) + } + return Effect.succeed( + Arr.map(tiers, (t) => (t.severity === tier ? { ...t, escalationChainId: chain } : t)), + ) + } + if (chain === null) return Effect.succeed(Arr.map(tiers, (t) => ({ ...t, escalationChainId: null }))) + const critical = Arr.findLastIndex(tiers, (t) => t.severity === "critical") const target = tiers.length === 1 ? 0 : Option.getOrElse(critical, () => tiers.length - 1) - return Arr.map(tiers, (tier, index) => (index === target ? { ...tier, escalationChainId: chain } : tier)) + return Effect.succeed( + Arr.map(tiers, (t, index) => (index === target ? { ...t, escalationChainId: chain } : t)), + ) } /** `destination_ids` replaces; add/remove edit the current (or replaced) list in place. */ diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index 58e0f284dc..06ae57746f 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -108,6 +108,7 @@ import { MutableHashMap, Option, Order, + Predicate, Random, Record, Result, @@ -2214,6 +2215,59 @@ export class AlertsService extends Context.Service, + ) { + const steps = Arr.filter( + rows, + (row) => + row.eventType === "trigger" && + row.incidentId !== null && + Predicate.hasProperty(row.payloadJson, "escalation"), + ) + const incidentIds = Arr.dedupe( + Arr.filterMap(steps, (row) => + row.incidentId === null ? Result.failVoid : Result.succeed(row.incidentId), + ), + ) + const orgIds = Arr.dedupe(Arr.map(steps, (row) => row.orgId)) + if (!Arr.isReadonlyArrayNonEmpty(incidentIds) || !Arr.isReadonlyArrayNonEmpty(orgIds)) { + return new Map() + } + const incidents = yield* dbExecute((db) => + db.run( + PG.from(AlertIncidents) + .select("id", "orgId", "status", "acknowledgedAt") + .where(($) => [$.orgId.in_(...orgIds), $.id.in_(...incidentIds)]), + ), + ) + const byKey = new Map( + Arr.map(incidents, (incident) => [`${incident.orgId}:${incident.id}`, incident]), + ) + const reasonOf = (row: AlertDeliveryEventRow): ReadonlyArray => { + const incident = byKey.get(`${row.orgId}:${row.incidentId}`) + if (incident === undefined) return [] + if (incident.status !== "open") { + return [ + [row.id, "Not sent: the incident resolved before this escalation step went out"], + ] + } + return incident.acknowledgedAt === null + ? [] + : [ + [ + row.id, + "Not sent: the incident was acknowledged before this escalation step went out", + ], + ] + } + return new Map(Arr.flatMap(steps, reasonOf)) + }) + const recordDeliveryFailure = Effect.fn("AlertsService.recordDeliveryFailure")(function* ( row: AlertDeliveryEventRow, currentTime: number, @@ -2458,32 +2512,11 @@ export class AlertsService extends Context.Service - db.run( - PG.from(AlertIncidents) - .select("status", "acknowledgedAt") - .where(($) => [$.orgId.eq(row.orgId), $.id.eq(stepIncidentId)]) - .limit(1), - ), - ))[0] ?? null) - : null - if ( - stepIncident !== null && - (stepIncident.status !== "open" || stepIncident.acknowledgedAt !== null) - ) { + // A queued escalation step goes out only to an incident still open and unacknowledged. + const staleStep = (yield* staleEscalationSteps([row])).get(row.id) + if (staleStep !== undefined) { yield* recordDeliveryFailure(row, claimTime, { - message: - stepIncident.status !== "open" - ? "Not sent: the incident resolved before this escalation step went out" - : "Not sent: the incident was acknowledged before this escalation step went out", + message: staleStep, kind: "escalation", retryable: false, }) @@ -2799,12 +2832,26 @@ export class AlertsService extends Context.Service count + claimed.length) - yield* Metric.update(AlertingMetrics.deliveriesAttemptedTotal, claimed.length) + // Escalation steps in the digest go out only for incidents still open and unacknowledged. + const staleSteps = yield* staleEscalationSteps(claimed) + yield* Effect.forEach( + Arr.filter(claimed, (row) => staleSteps.has(row.id)), + (row) => + recordDeliveryFailure(row, claimTime, { + message: staleSteps.get(row.id) ?? "Not sent", + kind: "escalation", + retryable: false, + }), + { discard: true }, + ) + const sendable = Arr.filter(claimed, (row) => !staleSteps.has(row.id)) + if (sendable.length === 0) return + yield* Ref.update(processedCount, (count) => count + sendable.length) + yield* Metric.update(AlertingMetrics.deliveriesAttemptedTotal, sendable.length) yield* Effect.annotateCurrentSpan({ "maple.delivery.destination_id": destinationRow.id, "maple.delivery.destination_type": "chat", - "maple.delivery.digest_size": claimed.length, + "maple.delivery.digest_size": sendable.length, }) yield* Effect.gen(function* () { const hydrated = yield* hydrateDestination(destinationRow) @@ -2814,7 +2861,7 @@ export class AlertsService extends Context.Service Effect.forEach( - claimed, + sendable, (row, index) => recoverDeliveryFailure(row, error, index === 0).pipe( isolate("Alert delivery recovery failed", { @@ -2836,14 +2883,14 @@ export class AlertsService extends Context.Service - finalizeDigest(destinationRow, claimed, claimTime, { - providerMessage: `Delivered to ${posted.connectorName} #${channelName} in a digest of ${claimed.length}`, + finalizeDigest(destinationRow, sendable, claimTime, { + providerMessage: `Delivered to ${posted.connectorName} #${channelName} in a digest of ${sendable.length}`, providerReference: posted.messageId, }), }), Effect.catchDefect((defect) => Effect.forEach( - claimed, + sendable, (row) => recoverDeliveryDefect(row, defect).pipe( isolate("Alert delivery recovery failed", { From fb84b4c6dbfc80a8283a5986d3e9800bce020c13 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 14:56:09 +0100 Subject: [PATCH 50/98] fix(alerts): a destination a running escalation still pages cannot be deleted --- .../alerts/AlertDestinationsService.ts | 26 +++++++++++++---- .../alerts/AlertsService.escalation.test.ts | 29 +++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/services/alerts/AlertDestinationsService.ts b/packages/backend/src/services/alerts/AlertDestinationsService.ts index 2b692e72ab..8314111cce 100644 --- a/packages/backend/src/services/alerts/AlertDestinationsService.ts +++ b/packages/backend/src/services/alerts/AlertDestinationsService.ts @@ -31,6 +31,7 @@ import * as Orm from "@maple-dev/effect-orm/database" import * as PG from "@maple-dev/effect-orm/postgres" import { AlertDestinations, + AlertEscalationRuns, AlertLinearIssues, AlertRules, EscalationChains, @@ -1066,12 +1067,25 @@ export class AlertDestinationsService extends Context.Service< ])}::jsonb`, ]), ) - if (chains.length > 0) { - return { - referencedBy: [], - chainNames: Arr.map(chains, (chain) => chain.name), - deleted: [], - } + // A running escalation pages from its own snapshot, so a chain edited or deleted since + // can still name the destination until the run ends. + const runs = yield* db.run( + PG.from(AlertEscalationRuns) + .select("chainName") + .where(($) => [ + $.orgId.eq(orgId), + $.status.in_("active", "acknowledged"), + PG.sql.cond`${$.stepsJson} @> ${encodeJson([ + { targets: [{ type: "destination", destinationId }] }, + ])}::jsonb`, + ]), + ) + const usedBy = Arr.dedupe([ + ...Arr.map(chains, (chain) => chain.name), + ...Arr.map(runs, (run) => `${run.chainName} (escalating an open incident)`), + ]) + if (usedBy.length > 0) { + return { referencedBy: [], chainNames: usedBy, deleted: [] } } const deleted = yield* db.run( PG.deleteFrom(AlertDestinations) diff --git a/packages/backend/src/services/alerts/AlertsService.escalation.test.ts b/packages/backend/src/services/alerts/AlertsService.escalation.test.ts index c1d2d28314..61e75dd01e 100644 --- a/packages/backend/src/services/alerts/AlertsService.escalation.test.ts +++ b/packages/backend/src/services/alerts/AlertsService.escalation.test.ts @@ -5,6 +5,7 @@ import { EscalationChainCreateRequest, EscalationChainId, EscalationChainInUseError, + EscalationChainUpdateRequest, WebhookAlertDestinationConfig, type AlertDestinationId, type AlertThreshold, @@ -495,6 +496,34 @@ describe("escalation chains", () => { }).pipe(Effect.provide(layerFor(testDb, state, requests))) }) + it.effect("refuses deleting a destination a running escalation still pages from its snapshot", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRateRows(10) } + const requests: Array = [] + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const { alerts, chains, orgId, userId, slack, pager, chain } = + yield* setup("org_esc_snapshot_guard") + yield* alerts.runSchedulerTick() + yield* tick(alerts) + // The chain stops naming slack, but the open incident's run still has it in its snapshot. + yield* chains.update( + orgId, + userId, + adminRoles, + chain.id, + new EscalationChainUpdateRequest({ + steps: [{ targets: [{ type: "destination", destinationId: pager.id }], waitMinutes: 10 }], + }), + ) + const refused = getError( + yield* Effect.exit(alerts.deleteDestination(orgId, adminRoles, slack.id)), + ) + if (!Schema.is(AlertDestinationInUseError)(refused)) return assert.fail("expected in use") + assert.include(refused.message, "escalating an open incident") + }).pipe(Effect.provide(layerFor(testDb, state, requests))) + }) + it.effect("refuses a rule naming an unknown chain, and deleting a chain or destination in use", () => { const testDb = createTestDb(trackedDbs) const state = { tracesAggregateRows: errorRateRows(10) } From fc45bd87e8498fe1e007ec63636d7c1acbc5c7fa Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 15:05:45 +0100 Subject: [PATCH 51/98] fix(web): each tier's escalation chain picker has its own accessible name --- .../alerts/editor/editor-chains.scene.test.ts | 11 ++++++++++ .../page/alerts/editor/view-notifications.ts | 20 +++++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts index 4565814c11..8a5d99ce5c 100644 --- a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts @@ -66,6 +66,17 @@ describe("alert rule editor, escalation chains", () => { ) }) + test("a two-tier rule names each picker by its tier", () => { + scene( + { update: pageUpdate, view }, + given(run(ready(), [Message.ClickedAddCriticalTier()])), + themeResolved(), + expectView(role("combobox", { name: "Warning escalation chain" })).toExist(), + expectView(role("combobox", { name: "Critical escalation chain" })).toExist(), + expectView(role("combobox", { name: "Escalation chain" })).not.toExist(), + ) + }) + test("a tier with neither a destination nor a chain says so and cannot be saved", () => { const model = withoutDestination(ready()) expect(issuesOf(model)).toContain("Who gets notified") diff --git a/apps/web/src/page/alerts/editor/view-notifications.ts b/apps/web/src/page/alerts/editor/view-notifications.ts index be2b6024c6..281c512c3c 100644 --- a/apps/web/src/page/alerts/editor/view-notifications.ts +++ b/apps/web/src/page/alerts/editor/view-notifications.ts @@ -98,6 +98,7 @@ const chainPicker = ( tier: Tier, chainId: EscalationChainId | null, destinationIds: ReadonlyArray, + label: string, ): Html => { const id = chainSelectIdOf(tier) const ids = idsOf(id, false) @@ -134,7 +135,7 @@ const chainPicker = ( toView: (parts) => field(h, { className: "items-stretch gap-1.5", state }, [ hstack(h, { gap: "2", justify: "between" }, [ - fieldLabel(h, { ids, state, className: "text-xs" }, "Escalation chain"), + fieldLabel(h, { ids, state, className: "text-xs" }, label), textLink( h, { @@ -152,17 +153,26 @@ const chainPicker = ( }) } -/** One tier's destinations, then the chain it starts. */ +/** One tier's destinations, then the chain it starts; `tiered` names the picker by its tier. */ const tierRouting = ( h: H, model: NotificationsView, tier: Tier, destinationIds: ReadonlyArray, chainId: EscalationChainId | null, + tiered: boolean, ): Html => vstack(h, { gap: "3" }, [ destinationList(h, model, tier, destinationIds, destinationsOf(model)), - chainPicker(h, model, tier, chainId, destinationIds), + chainPicker( + h, + model, + tier, + chainId, + destinationIds, + // Two tiers render two pickers, so each needs its own accessible name. + tiered ? `${tier === "warning" ? "Warning" : "Critical"} escalation chain` : "Escalation chain", + ), ]) /** `MessageTemplate`: collapsed until opened, or open when the rule has one. */ @@ -281,7 +291,7 @@ const notifications = (h: H, model: NotificationsView, form: NotificationsForm): ] : []), ...(escalation === null - ? [tierRouting(h, model, "warning", form.destinationIds, form.escalationChainId)] + ? [tierRouting(h, model, "warning", form.destinationIds, form.escalationChainId, false)] : [ h.div( [h.Class("mt-1 space-y-4")], @@ -296,6 +306,7 @@ const notifications = (h: H, model: NotificationsView, form: NotificationsForm): "warning", form.destinationIds, form.escalationChainId, + true, ), ], ), @@ -313,6 +324,7 @@ const notifications = (h: H, model: NotificationsView, form: NotificationsForm): "critical", escalation.destinationIds, escalation.escalationChainId, + true, ), ], ), From 708505378133b9299d1846117ea5cc0c548ace7e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 15:06:52 +0100 Subject: [PATCH 52/98] fix(alerts): the pages read cap drops the oldest pages, never the newest --- .../alerts/AlertIncidentPagesService.ts | 5 ++-- .../AlertsService.oncall-paging.test.ts | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/services/alerts/AlertIncidentPagesService.ts b/packages/backend/src/services/alerts/AlertIncidentPagesService.ts index 68b59895cf..1caf288a56 100644 --- a/packages/backend/src/services/alerts/AlertIncidentPagesService.ts +++ b/packages/backend/src/services/alerts/AlertIncidentPagesService.ts @@ -93,7 +93,8 @@ export class AlertIncidentPagesService extends Context.Service< "createdAt", ) .where(($) => [$.orgId.eq(orgId), $.incidentId.in_(...incidentIds)]) - .orderBy(["createdAt", "asc"], ["id", "asc"]) + // Newest first, so the cap only ever drops the oldest pages; put back in order below. + .orderBy(["createdAt", "desc"], ["id", "desc"]) .limit(MAX_PAGES_PER_READ), ), ) @@ -101,7 +102,7 @@ export class AlertIncidentPagesService extends Context.Service< // Display names only, never the address; a directory that cannot be read leaves them null. const members = yield* orgMembers.listMembers(orgId).pipe(Effect.orElseSucceed(() => [])) const names = new Map(Arr.map(members, (member) => [member.userId, member.name] as const)) - return Arr.map(rows, (row): AlertIncidentPageEntry => ({ + return Arr.map(Arr.reverse(rows), (row): AlertIncidentPageEntry => ({ id: row.id, incidentId: row.incidentId, createdAt: row.createdAt, diff --git a/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts b/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts index a5774addbb..448b7f0875 100644 --- a/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts +++ b/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts @@ -490,6 +490,30 @@ describe("escalation pages the on-call", () => { ], ) assert.notInclude(JSON.stringify(pages), "ben@acme.test") + + // Past the read cap the oldest pages drop, never the newest a responder is reading. + yield* Effect.promise(() => + executeSql( + testDb, + `insert into alert_member_notifications (id, org_id, incident_id, run_id, user_id, channel, + status, attempt_number, scheduled_at, payload_json, dedupe_key, created_at, updated_at) + select gen_random_uuid()::text, org_id, incident_id, run_id, user_id, 'email', 'sent', 1, + now(), '{}'::jsonb, 'bulk:' || n, now() + (n || ' seconds')::interval, now() + from alert_member_notifications, generate_series(1, 600) as n + where org_id = $1 and channel = 'email'`, + [orgId], + ), + ) + const capped = yield* (yield* AlertIncidentPagesService).listPages(orgId, [incident.id]) + assert.lengthOf(capped, 500) + const last = capped[capped.length - 1] + const newest = yield* Effect.promise(() => + testDb.pglite.query<{ id: string }>( + "select id from alert_member_notifications where org_id = $1 order by created_at desc, id desc limit 1", + [orgId], + ), + ) + assert.strictEqual(last?.id, newest.rows[0]?.id) }).pipe(Effect.provide(layerFor(testDb, state, emails, chat))) }) }) From 2e585cfb75864749aba7278983b20b752674241d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 15:13:54 +0100 Subject: [PATCH 53/98] fix(web): each step's wait input is named by its step; an unacknowledged run reads as resuming --- .../alerts/escalation-dialog/dialog.scene.test.ts | 2 +- .../web/src/page/alerts/escalation-dialog/view.ts | 15 +++++++++------ .../page/alerts/shared/escalation-chains.test.ts | 8 +++++++- .../src/page/alerts/shared/escalation-chains.ts | 5 ++++- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts index 047913f245..34f99e0a65 100644 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts +++ b/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts @@ -61,7 +61,7 @@ describe("escalation chain dialog view", () => { expectView(role("spinbutton", { name: "Repeat the whole chain" })).toHaveAccessibleDescription( `Extra passes after the first, 0 to ${MAX_ESCALATION_REPEATS}. 0 runs it once.`, ), - expectView(role("spinbutton", { name: /^Then wait/ })).toHaveAttr( + expectView(role("spinbutton", { name: /^Step 1: then wait/ })).toHaveAttr( "max", String(MAX_ESCALATION_WAIT_MINUTES), ), diff --git a/apps/web/src/page/alerts/escalation-dialog/view.ts b/apps/web/src/page/alerts/escalation-dialog/view.ts index 1691a2f13e..b9e18f807d 100644 --- a/apps/web/src/page/alerts/escalation-dialog/view.ts +++ b/apps/web/src/page/alerts/escalation-dialog/view.ts @@ -149,13 +149,16 @@ const numberField = ( const isOutOfRange = (raw: string, error: Option.Option) => raw !== "" && Option.isSome(error) +// Numbered, so each step's wait input has its own accessible name. const waitLabel = (model: Model, index: number): string => - index < model.steps.length - 1 - ? "Then wait, in minutes" - : (numberOf(model.repeatCount) ?? 0) > 0 - ? "Wait before repeating, in minutes" - : // The run ends as soon as the last step pages, so this wait only matters once it repeats. - "Wait before repeating, in minutes (unused unless the chain repeats)" + `Step ${index + 1}: ${ + index < model.steps.length - 1 + ? "then wait, in minutes" + : (numberOf(model.repeatCount) ?? 0) > 0 + ? "wait before repeating, in minutes" + : // The run ends as soon as the last step pages, so this wait only matters once it repeats. + "wait before repeating, in minutes (unused unless the chain repeats)" + }` const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}` diff --git a/apps/web/src/page/alerts/shared/escalation-chains.test.ts b/apps/web/src/page/alerts/shared/escalation-chains.test.ts index 100484bab7..b3e408cbf9 100644 --- a/apps/web/src/page/alerts/shared/escalation-chains.test.ts +++ b/apps/web/src/page/alerts/shared/escalation-chains.test.ts @@ -68,7 +68,13 @@ describe("runView", () => { state: "paused", label: "Paused: acknowledged", }) - expect(runView(runOf("acknowledged"), false, "UTC", NOW).state).toBe("paused") + }) + + it("an unacknowledged incident whose run the scheduler has not resumed yet reads as resuming", () => { + expect(runView(runOf("acknowledged"), false, "UTC", NOW)).toMatchObject({ + state: "escalating", + label: "Resuming", + }) }) it("an ended run says why it ended", () => { diff --git a/apps/web/src/page/alerts/shared/escalation-chains.ts b/apps/web/src/page/alerts/shared/escalation-chains.ts index a75ccb1783..14b79e272c 100644 --- a/apps/web/src/page/alerts/shared/escalation-chains.ts +++ b/apps/web/src/page/alerts/shared/escalation-chains.ts @@ -132,12 +132,15 @@ export const runView = ( const position = `Step ${run.step} of ${run.step_count}${run.pass_count > 1 ? `, pass ${run.pass} of ${run.pass_count}` : ""}` if (run.status === "resolved" || run.status === "exhausted" || run.status === "superseded") return { state: "finished", label: "Finished", detail: ENDED[run.status] } - if (run.status === "acknowledged" || isAcknowledged) + if (isAcknowledged) return { state: "paused", label: "Paused: acknowledged", detail: `${position}. Resumes if the incident is unacknowledged`, } + // Unacknowledged, but the scheduler has not picked the run back up yet. + if (run.status === "acknowledged") + return { state: "escalating", label: "Resuming", detail: `${position}, on the next check` } return { state: "escalating", label: "Escalating", From 0e314e95e0c97d84d5d86245c88b9d272ec611d6 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 15:14:02 +0100 Subject: [PATCH 54/98] fix(views): shift timeline bars reach the keyboard, stay hittable, and the axis reads as a table --- .../views/src/ui/shift-timeline.scene.test.ts | 76 +++++++++++++++++++ packages/views/src/ui/shift-timeline.ts | 30 ++++++-- 2 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 packages/views/src/ui/shift-timeline.scene.test.ts diff --git a/packages/views/src/ui/shift-timeline.scene.test.ts b/packages/views/src/ui/shift-timeline.scene.test.ts new file mode 100644 index 0000000000..f9cdc0fafc --- /dev/null +++ b/packages/views/src/ui/shift-timeline.scene.test.ts @@ -0,0 +1,76 @@ +import { expect as expectView, given, role, scene, text } from "foldkit/scene" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { describe, test } from "vitest" +import { shiftTimeline } from "./shift-timeline" + +const Message = defineMessageUnion({ ClickedBar: {} }) +type Message = typeof Message.Type +type Model = Readonly<{ nowMs: number }> + +const HOUR = 3_600_000 +const FROM = Date.parse("2026-11-09T00:00:00.000Z") + +const view = (model: Model, h: HtmlBuilder): Html => + shiftTimeline(h, { + fromMs: FROM, + toMs: FROM + 7 * 24 * HOUR, + timeZone: "UTC", + nowMs: model.nowMs, + ariaLabel: "Shifts", + nowLabel: "Now, Tue 10 Nov, 11:30", + rows: [ + { + key: "final", + label: "Final schedule", + bars: [ + { + key: "a", + startMs: FROM, + endMs: FROM + HOUR, + label: "Ana", + title: "Ana, 1 hour", + tone: "primary", + onClick: Message.ClickedBar(), + }, + ], + }, + { + key: "layer", + label: "Primary", + bars: [ + { + key: "b", + startMs: FROM, + endMs: FROM + 24 * HOUR, + label: "Ben", + title: "Ben, Mon", + tone: "neutral", + }, + ], + }, + ], + }) +const update = (model: Model) => ({ model, commands: [] }) + +describe("shift timeline", () => { + test("reads as a table: day column headers, a row header per lane, and every bar named", () => { + scene( + { update, view }, + given({ nowMs: FROM + 35 * HOUR }), + expectView(role("columnheader", { name: "Mon 9" })).toExist(), + expectView(role("rowheader", { name: /Primary/u })).toExist(), + expectView(role("button", { name: "Ana, 1 hour" })).toExist(), + expectView(role("img", { name: "Ben, Mon" })).toExist(), + expectView(text("Now, Tue 10 Nov, 11:30")).toExist(), + ) + }) + + test("says nothing of now when it is outside the range", () => { + scene( + { update, view }, + given({ nowMs: FROM - HOUR }), + expectView(text("Now, Tue 10 Nov, 11:30")).not.toExist(), + ) + }) +}) diff --git a/packages/views/src/ui/shift-timeline.ts b/packages/views/src/ui/shift-timeline.ts index a4c7846b1e..1def535189 100644 --- a/packages/views/src/ui/shift-timeline.ts +++ b/packages/views/src/ui/shift-timeline.ts @@ -104,6 +104,11 @@ const BAR_TONE = { info: toneBoxClass("info"), } as const satisfies Record +/** The narrowest a bar draws, so a short shift stays visible and clickable. */ +const MIN_BAR_PX = 8 + +const FOCUS_CLASS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40" + const BAR_CLASS = "absolute inset-y-1 overflow-hidden rounded-sm border px-1.5 text-left" const barView = ( @@ -115,8 +120,12 @@ const barView = ( Option.match(placeBar(fromMs, toMs, bar.startMs, bar.endMs), { onNone: () => [], onSome: ({ leftPct, widthPct }) => { - // A 2px gutter keeps back-to-back shifts apart. - const style = h.Style({ left: `${leftPct}%`, width: `calc(${widthPct}% - 2px)` }) + // A 2px gutter keeps back-to-back shifts apart; a floor keeps an hour-long one hittable. + const style = h.Style({ + left: `${leftPct}%`, + width: `calc(${widthPct}% - 2px)`, + "min-width": `${MIN_BAR_PX}px`, + }) const text = typography(h, { size: "xs", truncate: true, className: "block leading-6" }, [ bar.label, ]) @@ -126,7 +135,10 @@ const barView = ( ? h.div( [ ...attributes, - h.Class(cn(BAR_CLASS, BAR_TONE[bar.tone])), + h.Class(cn(BAR_CLASS, BAR_TONE[bar.tone], FOCUS_CLASS)), + // Read-only bars still reach the keyboard and a screen reader, named by their tooltip. + h.Role("img"), + h.Tabindex(0), h.AriaLabel(bar.title), ], [text], @@ -164,6 +176,8 @@ export const shiftTimeline = ( nowMs: number rows: ReadonlyArray> ariaLabel: string + /** Read out in place of the now line, which is drawn only ("Now, Tue 10 Nov, 11:30"). */ + nowLabel?: string /** A key under the timeline for the bar tones it uses. */ legend?: ReadonlyArray> }>, @@ -191,7 +205,7 @@ export const shiftTimeline = ( [ h.Key("now"), h.DataAttribute("slot", "timeline-now"), - h.AriaLabel("Now"), + h.AriaHidden(true), h.Class("pointer-events-none absolute inset-y-0 w-px bg-primary"), h.Style({ left: `${leftPct}%` }), ], @@ -200,15 +214,16 @@ export const shiftTimeline = ( ], }) const header = h.div( - [h.Class("flex border-b")], + [h.Class("flex border-b"), h.Role("row")], [ - h.div([h.Class(LABEL_COLUMN)], []), + h.div([h.Class(LABEL_COLUMN), h.Role("columnheader")], [h.span([h.Class("sr-only")], ["Lane"])]), h.div( [h.Class("relative h-8 grow")], Arr.map(days, (day) => h.div( [ h.Key(`day-${day.startMs}`), + h.Role("columnheader"), h.Class( "absolute inset-y-0 overflow-hidden border-l border-border-subtle px-1.5 py-1.5", ), @@ -277,6 +292,9 @@ export const shiftTimeline = ( h.div( [h.Class("relative min-w-[720px]"), h.Role("table"), h.AriaLabel(options.ariaLabel)], [ + ...(options.nowLabel === undefined || Option.isNone(now) + ? [] + : [h.p([h.Class("sr-only")], [options.nowLabel])]), header, h.div( [h.Class("relative")], From da700a9fbd1882a11f7bd1d0ded2a7325efdbffd Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 15:14:02 +0100 Subject: [PATCH 55/98] fix(alerts): on-call pickers read every member page, and an unloaded member reads neutrally --- .../page/alerts/shared/org-members.test.ts | 57 +++++++++++ .../web/src/page/alerts/shared/org-members.ts | 98 ++++++++++++------- 2 files changed, 120 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/page/alerts/shared/org-members.test.ts diff --git a/apps/web/src/page/alerts/shared/org-members.test.ts b/apps/web/src/page/alerts/shared/org-members.test.ts new file mode 100644 index 0000000000..ad6ed22284 --- /dev/null +++ b/apps/web/src/page/alerts/shared/org-members.test.ts @@ -0,0 +1,57 @@ +import { Effect, Array as Arr } from "effect" +import { describe, expect, it } from "vitest" +import type { ClerkMembershipResource } from "../../../platform/clerk" +import { MAX_MEMBERS, memberName, membersOf } from "./org-members" + +const membership = (n: number): ClerkMembershipResource => ({ + id: `mem_${n}`, + role: "org:member", + createdAt: new Date(0), + publicUserData: { + userId: `user_${n}`, + firstName: `Person`, + lastName: String(n), + identifier: `person${n}@acme.test`, + imageUrl: "", + hasImage: false, + }, + update: () => Promise.resolve(), + destroy: () => Promise.resolve(), +}) + +/** An org of `total` members, served 100 a page, counting the pages asked for. */ +const organizationOf = (total: number) => { + const asked: Array = [] + return { + asked, + getMemberships: ({ initialPage = 1, pageSize = 10 }: { initialPage?: number; pageSize?: number }) => { + asked.push(initialPage) + const start = (initialPage - 1) * pageSize + return Promise.resolve({ + data: Arr.map(Arr.range(start, Math.min(start + pageSize, total) - 1), membership), + total_count: total, + }) + }, + } +} + +describe("org members", () => { + it("reads every page of a large org, not just the first 100", async () => { + const organization = organizationOf(250) + const members = await Effect.runPromise(membersOf(organization)) + expect(members).toHaveLength(250) + expect(memberName(members, "user_249")).toBe("Person 249") + expect([...organization.asked].sort()).toEqual([1, 2, 3]) + }) + + it("stops at the cap, and names a member past it neutrally rather than as gone", async () => { + const members = await Effect.runPromise(membersOf(organizationOf(MAX_MEMBERS + 150))) + expect(members).toHaveLength(MAX_MEMBERS) + expect(memberName(members, `user_${MAX_MEMBERS + 10}`)).toBe("Member") + }) + + it("reads unknown ids as former members only once the whole directory is known", () => { + expect(memberName(null, "user_1")).toBe("Member") + expect(memberName([], "user_1")).toBe("A former member") + }) +}) diff --git a/apps/web/src/page/alerts/shared/org-members.ts b/apps/web/src/page/alerts/shared/org-members.ts index 83c9466d36..92651bae91 100644 --- a/apps/web/src/page/alerts/shared/org-members.ts +++ b/apps/web/src/page/alerts/shared/org-members.ts @@ -1,10 +1,17 @@ import { UserId } from "@maple/domain/http" import { Array as Arr, Effect, Option, Schema } from "effect" -import { clerkTry, currentClerkOrganization } from "../../../platform/clerk" +import { + type ClerkMembershipResource, + type ClerkOrganizationResource, + clerkTry, + currentClerkOrganization, +} from "../../../platform/clerk" -// The org's members as the on-call pickers and names read them: Clerk's memberships, first 100. +// The org's members as the on-call pickers and names read them: Clerk's memberships, every page +// up to `MAX_MEMBERS` (an org past that names the rest "Member" and cannot pick them here). const PAGE_SIZE = 100 +export const MAX_MEMBERS = 2000 export const OrgMember = Schema.Struct({ userId: UserId, @@ -15,44 +22,65 @@ export const OrgMember = Schema.Struct({ }) export type OrgMember = typeof OrgMember.Type -const decodeUserId = Schema.decodeUnknownOption(UserId) +const decodeUserIdOption = Schema.decodeUnknownOption(UserId) -/** Every member with a user id, named by first and last name, else by email. */ +const memberOfMembership = (member: ClerkMembershipResource): ReadonlyArray => { + const user = member.publicUserData + const email = user?.identifier ?? "" + const name = Arr.join( + Arr.filter([user?.firstName, user?.lastName], (part): part is string => Boolean(part)), + " ", + ) + return Option.match(decodeUserIdOption(user?.userId), { + onNone: () => [], + onSome: (userId) => [ + { + userId, + name: name || email || "Unknown member", + email, + imageUrl: user?.hasImage === true ? user.imageUrl : "", + }, + ], + }) +} + +/** Every member of `organization` with a user id, named by first and last name, else by email. */ +export const membersOf = ( + organization: Pick, +): Effect.Effect, string> => + Effect.gen(function* () { + const page = (initialPage: number) => + clerkTry("Couldn't load members", () => + organization.getMemberships({ initialPage, pageSize: PAGE_SIZE }), + ) + const first = yield* page(1) + const pageCount = Math.ceil(Math.min(first.total_count, MAX_MEMBERS) / PAGE_SIZE) + // The rest of the pages, 4 at a time. + const rest = yield* Effect.forEach(pageCount > 1 ? Arr.range(2, pageCount) : [], page, { + concurrency: 4, + }) + return Arr.flatMap([first, ...rest], (result) => Arr.flatMap(result.data, memberOfMembership)) + }).pipe(Effect.mapError((error) => error.message)) + +/** The current org's members. */ export const listOrgMembers: Effect.Effect, string> = currentClerkOrganization.pipe( - Effect.flatMap((organization) => - clerkTry("Couldn't load members", () => - organization.getMemberships({ initialPage: 1, pageSize: PAGE_SIZE }), - ), - ), - Effect.mapBoth({ - onSuccess: (page) => - Arr.flatMap(page.data, (member): ReadonlyArray => { - const user = member.publicUserData - const email = user?.identifier ?? "" - const name = Arr.join( - Arr.filter([user?.firstName, user?.lastName], (part): part is string => Boolean(part)), - " ", - ) - return Option.match(decodeUserId(user?.userId), { - onNone: () => [], - onSome: (userId) => [ - { - userId, - name: name || email || "Unknown member", - email, - imageUrl: user?.hasImage === true ? user.imageUrl : "", - }, - ], - }) - }), - onFailure: (error) => error.message, - }), + Effect.mapError((error) => error.message), + Effect.flatMap(membersOf), ) -/** A member by id; someone who left the org reads as "A former member", and "Member" until they load. */ +/** + * A member by id; someone who left the org reads as "A former member". Until the members load, after + * a failed read, or in an org past `MAX_MEMBERS`, an unknown id reads neutrally as "Member". + */ export const memberName = (members: ReadonlyArray | null, userId: string): string => - members === null - ? "Member" + members === null || members.length >= MAX_MEMBERS + ? Option.getOrElse( + Option.map( + Arr.findFirst(members ?? [], (member) => member.userId === userId), + (member) => member.name, + ), + () => "Member", + ) : Option.getOrElse( Option.map( Arr.findFirst(members, (member) => member.userId === userId), From 25a5e170ef1c3ac88a94f71513723a3e75bffd03 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 15:14:09 +0100 Subject: [PATCH 56/98] fix(alerts): schedule overrides cover whole shifts, ask before deleting, and the header follows handoffs Shifts are read 14 days past the drawn week, so a clicked shift keeps its real bounds; a week still loading shows the skeleton; a failed shifts read waits for Try again. --- apps/web/src/lab/oncall-lab.ts | 5 +- apps/web/src/page/alerts/schedule/command.ts | 9 +- apps/web/src/page/alerts/schedule/message.ts | 11 +- apps/web/src/page/alerts/schedule/model.ts | 7 + .../alerts/schedule/schedule.scene.test.ts | 200 ++++++++++++++++-- apps/web/src/page/alerts/schedule/update.ts | 180 ++++++++++++---- apps/web/src/page/alerts/schedule/view.ts | 79 +++++-- .../alerts/shared/oncall-test-fixtures.ts | 2 +- 8 files changed, 402 insertions(+), 91 deletions(-) diff --git a/apps/web/src/lab/oncall-lab.ts b/apps/web/src/lab/oncall-lab.ts index 131d7a88c8..36dbaa6362 100644 --- a/apps/web/src/lab/oncall-lab.ts +++ b/apps/web/src/lab/oncall-lab.ts @@ -277,6 +277,7 @@ const scheduleFor = (state: LabState): Step ({ fromMs, toMs: fromMs })) const withShifts = fold( loaded, [ ScheduleMessage.Message.CompletedFetchShifts({ key: loaded.model.shifts.key ?? "", - result: Result.succeed(checkoutShifts(nowMs, fromMs, toMs)), + result: Result.succeed(checkoutShifts(nowMs, read.fromMs, read.toMs)), }), ], scheduleStep, diff --git a/apps/web/src/page/alerts/schedule/command.ts b/apps/web/src/page/alerts/schedule/command.ts index a89739dbe2..9cceccf980 100644 --- a/apps/web/src/page/alerts/schedule/command.ts +++ b/apps/web/src/page/alerts/schedule/command.ts @@ -22,10 +22,11 @@ export const FetchSchedule = Command.define("FetchOnCallSchedule", { args: { key: Schema.String, scheduleId: OnCallScheduleId }, messages: [Message.CompletedFetchSchedule], execute: ({ key, scheduleId }) => - retrieveOnCallSchedule(scheduleId).pipe( - Effect.result, - Effect.map((result) => Message.CompletedFetchSchedule({ key, result })), - ), + Effect.gen(function* () { + const result = yield* Effect.result(retrieveOnCallSchedule(scheduleId)) + const readAtMs = yield* Clock.currentTimeMillis + return Message.CompletedFetchSchedule({ key, readAtMs, result }) + }), }) export const FetchShifts = Command.define("FetchOnCallShifts", { diff --git a/apps/web/src/page/alerts/schedule/message.ts b/apps/web/src/page/alerts/schedule/message.ts index a7eff5dc4c..68944ecdaf 100644 --- a/apps/web/src/page/alerts/schedule/message.ts +++ b/apps/web/src/page/alerts/schedule/message.ts @@ -11,16 +11,21 @@ const outcome = (success: S) => Schema.Result(success, Loa export const Message = defineMessageUnion({ ReadClock: { nowMs: Schema.Number }, - CompletedFetchSchedule: { key: Schema.String, result: outcome(V2OnCallSchedule) }, + CompletedFetchSchedule: { + key: Schema.String, + readAtMs: Schema.Number, + result: outcome(V2OnCallSchedule), + }, CompletedFetchShifts: { key: Schema.String, result: outcome(V2OnCallShifts) }, CompletedFetchOverrides: { key: Schema.String, result: outcome(Schema.Array(V2OnCallOverride)) }, CompletedFetchMembers: { result: Schema.Result(Schema.Array(OrgMember), Schema.String) }, CompletedFetchSession: { orgId: Schema.String, grants: Schema.Array(Policy.PermissionName) }, ClickedRetry: {}, + ClickedRetryShifts: {}, ClickedPreviousWeek: {}, ClickedNextWeek: {}, ClickedThisWeek: {}, - /** A final shift's bar: an override for its range, its person preselected. */ + /** A final shift's bar: an override over its whole range, for someone else to cover. */ ClickedShift: { startMs: Schema.Number, endMs: Schema.Number }, ClickedNewOverride: {}, GotOverrideDialogMessage: { message: Dialog.Message }, @@ -30,6 +35,8 @@ export const Message = defineMessageUnion({ ClickedCreateOverride: {}, CompletedCreateOverride: { openCount: Schema.Number, result: outcome(V2OnCallOverride) }, ClickedDeleteOverride: { overrideId: OnCallOverrideId }, + ClickedConfirmDeleteOverride: {}, + GotDeleteOverrideDialogMessage: { message: Dialog.Message }, CompletedDeleteOverride: { overrideId: OnCallOverrideId, result: outcome(Schema.Void) }, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/schedule/model.ts b/apps/web/src/page/alerts/schedule/model.ts index fd46ad1b59..8ca823d278 100644 --- a/apps/web/src/page/alerts/schedule/model.ts +++ b/apps/web/src/page/alerts/schedule/model.ts @@ -14,6 +14,9 @@ export const OverrideDraft = Schema.Struct({ userId: Schema.String, startsAt: Schema.String, endsAt: Schema.String, + /** The exact instants the dialog opened on, sent back until their text is edited. */ + startMs: Schema.NullOr(Schema.Number), + endMs: Schema.NullOr(Schema.Number), }) export type OverrideDraft = typeof OverrideDraft.Type @@ -27,6 +30,7 @@ export const Model = Schema.Struct({ weekOffset: Schema.Number, shifts: KeyedSchema(V2OnCallShifts, LoadFailure), overrides: KeyedSchema(Schema.Array(V2OnCallOverride), LoadFailure), + /** `null` until Clerk answers, and after a failed read: names then read neutrally. */ members: Schema.NullOr(Schema.Array(OrgMember)), grants: Schema.Array(Policy.PermissionName), overrideDialog: Dialog.Model, @@ -37,6 +41,9 @@ export const Model = Schema.Struct({ /** Bumped on every open: a create's result only settles the dialog it was sent from. */ overrideOpenCount: Schema.Number, deletingOverrideId: Schema.NullOr(OnCallOverrideId), + /** The override awaiting delete confirmation. */ + confirmDeleteOverrideId: Schema.NullOr(OnCallOverrideId), + deleteOverrideDialog: Dialog.Model, }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts index 9335ededb9..cb6100b792 100644 --- a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts +++ b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts @@ -3,6 +3,8 @@ import { OrgId } from "@maple/domain" import { OnCallOverrideId } from "@maple/domain/http" import { zonedDayStart } from "@maple/views/ui/shift-timeline" import { Option, Result, Schema, Array as Arr } from "effect" +import { AsyncData } from "foldkit" +import { isoMs } from "../shared/time" import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" @@ -20,7 +22,7 @@ import { import { ORG } from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" -import { CALENDAR_DAYS, init, overrideIssue, update } from "./update" +import { init, overrideIssue, rangeOf, readRangeOf, update } from "./update" import { view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)(ORG) @@ -47,6 +49,7 @@ const loaded = (nowMs: number, grants = ENGINEER) => { Message.ReadClock({ nowMs }), Message.CompletedFetchSchedule({ key: entered.model.schedule.key ?? "", + readAtMs: nowMs, result: Result.succeed(checkoutSchedule(nowMs)), }), Message.CompletedFetchOverrides({ @@ -54,16 +57,30 @@ const loaded = (nowMs: number, grants = ENGINEER) => { result: Result.succeed(checkoutOverrides(nowMs)), }), ]) - const fromMs = zonedDayStart(nowMs, BERLIN) - const toMs = zonedDayStart(nowMs, BERLIN, CALENDAR_DAYS) - return run(base, [ - Message.CompletedFetchShifts({ - key: base.shifts.key ?? "", - result: Result.succeed(checkoutShifts(nowMs, fromMs, toMs)), - }), - ]) + return withShifts(base, nowMs) } +/** The shifts the page asked for, over the range it asked for (wider than the week drawn). */ +const withShifts = (model: Model, nowMs: number) => + Option.match(readRangeOf(model), { + onNone: () => model, + onSome: ({ fromMs, toMs }) => + run(model, [ + Message.CompletedFetchShifts({ + key: model.shifts.key ?? "", + result: Result.succeed(checkoutShifts(nowMs, fromMs, toMs)), + }), + ]), + }) + +const commandArgs = ( + commands: ReadonlyArray<{ readonly name: string; readonly args?: unknown }> | undefined, + name: string, +) => + Arr.findFirst(commands ?? [], (command) => command.name === name).pipe( + Option.map((command) => command.args), + ) + describe("on-call schedule page", () => { test("the week reads each layer and the final row in the schedule's zone, noting the viewer's", () => { scene( @@ -93,14 +110,137 @@ describe("on-call schedule page", () => { ) }) - test("moving a week reads that week's shifts; Today comes back", () => { + test("moving a week reads that week's shifts and shows a skeleton, not last week's bars, until they land", () => { const model = loaded(ONCALL_NOW) const next = pageUpdate(model, Message.ClickedNextWeek()) expect(names(next.commands)).toEqual(["FetchOnCallShifts"]) expect(next.model.weekOffset).toBe(1) + scene( + { update: pageUpdate, view }, + given(next.model), + expectView(text("17 Nov to 23 Nov")).toExist(), + expectView(role("rowheader", { name: /Final schedule/u })).not.toExist(), + ) + scene( + { update: pageUpdate, view }, + given(withShifts(next.model, ONCALL_NOW)), + expectView(role("rowheader", { name: /Final schedule/u })).toExist(), + ) expect(pageUpdate(next.model, Message.ClickedThisWeek()).model.weekOffset).toBe(0) }) + test("an override over a shift crossing the week's edge covers the whole shift, not the drawn part", () => { + const model = loaded(ONCALL_NOW) + const weekEnd = Option.getOrElse( + Option.map(rangeOf(model), (range) => range.toMs), + () => 0, + ) + const shifts = Option.getOrElse(AsyncData.getData(model.shifts.data), () => checkoutShifts(0, 0, 1)) + const crossing = Arr.findFirst( + shifts.final, + (shift) => isoMs(shift.starts_at) < weekEnd && isoMs(shift.ends_at) > weekEnd, + ) + expect(Option.isSome(crossing)).toBe(true) + const shift = Option.getOrElse(crossing, () => shifts.final[0]) + const created = pageUpdate( + run(model, [ + Message.ClickedShift({ + startMs: isoMs(shift?.starts_at ?? ""), + endMs: isoMs(shift?.ends_at ?? ""), + }), + Message.ChangedOverrideUser({ value: memberId(7) }), + ]), + Message.ClickedCreateOverride(), + ) + expect(commandArgs(created.commands, "CreateOnCallOverride")).toEqual( + Option.some( + expect.objectContaining({ + payload: { user_id: memberId(7), starts_at: shift?.starts_at, ends_at: shift?.ends_at }, + }), + ), + ) + }) + + test("an override opened on an instant with seconds sends that instant back while its text is untouched", () => { + const startMs = Date.UTC(2026, 10, 12, 8, 0, 30) + const opened = run(loaded(ONCALL_NOW), [ + Message.ClickedShift({ startMs, endMs: startMs + 3_600_000 }), + Message.ChangedOverrideUser({ value: memberId(7) }), + ]) + const created = pageUpdate(opened, Message.ClickedCreateOverride()) + expect(commandArgs(created.commands, "CreateOnCallOverride")).toEqual( + Option.some( + expect.objectContaining({ + payload: expect.objectContaining({ starts_at: "2026-11-12T08:00:30.000Z" }), + }), + ), + ) + const edited = pageUpdate( + run(opened, [Message.ChangedOverrideStart({ value: "2026-11-12T10:00" })]), + Message.ClickedCreateOverride(), + ) + expect(commandArgs(edited.commands, "CreateOnCallOverride")).toEqual( + Option.some( + expect.objectContaining({ + payload: expect.objectContaining({ starts_at: "2026-11-12T09:00:00.000Z" }), + }), + ), + ) + }) + + test("the header's on-call is read again after its handoff passes, and after an override is written", () => { + const model = loaded(ONCALL_NOW) + const handoff = Option.getOrElse( + Option.flatMap(AsyncData.getData(model.schedule.data), (schedule) => + Option.map(Option.fromNullishOr(schedule.on_call_now), (now) => isoMs(now.ends_at)), + ), + () => 0, + ) + expect( + names(pageUpdate(model, Message.ReadClock({ nowMs: handoff - 60_000 })).commands), + ).not.toContain("FetchOnCallSchedule") + expect(names(pageUpdate(model, Message.ReadClock({ nowMs: handoff })).commands)).toContain( + "FetchOnCallSchedule", + ) + const created = pageUpdate( + run(model, [Message.ClickedNewOverride(), Message.ChangedOverrideUser({ value: memberId(7) })]), + Message.ClickedCreateOverride(), + ) + const done = pageUpdate( + created.model, + Message.CompletedCreateOverride({ + openCount: created.model.overrideOpenCount, + result: Result.succeed(Option.getOrThrow(Arr.head(checkoutOverrides(ONCALL_NOW)))), + }), + ) + expect(names(done.commands)).toContain("FetchOnCallSchedule") + }) + + test("a failed shifts read waits for Try again instead of retrying every clock tick", () => { + const model = loaded(ONCALL_NOW) + const failed = run(model, [Message.ClickedNextWeek()]) + const settled = run(failed, [ + Message.CompletedFetchShifts({ + key: failed.shifts.key ?? "", + result: Result.fail({ + _tag: "@maple/http/errors/AlertPersistenceError", + type: "api_error", + code: "persistence_error", + title: "Couldn't read", + message: "Couldn't read the schedule", + retryable: true, + recovery: "retry", + }), + }), + ]) + expect( + names(pageUpdate(settled, Message.ReadClock({ nowMs: ONCALL_NOW + 30_000 })).commands), + ).not.toContain("FetchOnCallShifts") + expect(names(pageUpdate(settled, Message.ClickedRetryShifts()).commands)).toContain( + "FetchOnCallShifts", + ) + }) + test("a final shift opens an override for its range; creating one sends the times in UTC", () => { const fromMs = zonedDayStart(ONCALL_NOW, BERLIN, 2) const opened = run(loaded(ONCALL_NOW), [ @@ -113,7 +253,13 @@ describe("on-call schedule page", () => { }) test("an override has to end after it starts and in the future", () => { - const draft = { userId: memberId(1), startsAt: "2026-11-12T10:00", endsAt: "2026-11-12T09:00" } + const draft = { + userId: memberId(1), + startsAt: "2026-11-12T10:00", + endsAt: "2026-11-12T09:00", + startMs: null, + endMs: null, + } expect(overrideIssue(draft, BERLIN, ONCALL_NOW)).toEqual( Option.some("End the override after it starts"), ) @@ -132,19 +278,33 @@ describe("on-call schedule page", () => { ) }) - test("deleting an override sends one delete, then reads the overrides and shifts again", () => { + test("deleting an override asks first, sends one delete, and reads the list again even when it fails", () => { const overrideId = Schema.decodeSync(OnCallOverrideId)(overrideUuid(1)) const asked = pageUpdate(loaded(ONCALL_NOW), Message.ClickedDeleteOverride({ overrideId })) - expect(names(asked.commands)).toEqual(["DeleteOnCallOverride"]) + expect(names(asked.commands)).not.toContain("DeleteOnCallOverride") + expect(asked.model.confirmDeleteOverrideId).toBe(overrideId) + const confirmed = pageUpdate(asked.model, Message.ClickedConfirmDeleteOverride()) + expect(names(confirmed.commands)).toContain("DeleteOnCallOverride") expect( - names(pageUpdate(asked.model, Message.ClickedDeleteOverride({ overrideId })).commands), - ).toEqual([]) - const done = pageUpdate( - asked.model, - Message.CompletedDeleteOverride({ overrideId, result: Result.void }), + names(pageUpdate(confirmed.model, Message.ClickedConfirmDeleteOverride()).commands), + ).not.toContain("DeleteOnCallOverride") + const gone = pageUpdate( + confirmed.model, + Message.CompletedDeleteOverride({ + overrideId, + result: Result.fail({ + _tag: "@maple/http/errors/OnCallOverrideNotFoundError", + type: "not_found_error", + code: "oncall_override_not_found", + title: "Override not found", + message: "The override no longer exists.", + retryable: false, + recovery: "fix_request", + }), + }), ) - expect(names(done.commands)).toContain("FetchOnCallOverrides") - expect(names(done.commands)).toContain("FetchOnCallShifts") + expect(names(gone.commands)).toContain("FetchOnCallOverrides") + expect(names(gone.commands)).toContain("FetchOnCallShifts") }) test("a member reads the calendar but cannot add or delete an override", () => { diff --git a/apps/web/src/page/alerts/schedule/update.ts b/apps/web/src/page/alerts/schedule/update.ts index 53ababa8d1..236a971024 100644 --- a/apps/web/src/page/alerts/schedule/update.ts +++ b/apps/web/src/page/alerts/schedule/update.ts @@ -22,12 +22,13 @@ import { } from "./command" import { Message } from "./message" import { canManage, type Model, type OverrideDraft } from "./model" -import { msIso } from "../shared/time" +import { isoMs, msIso } from "../shared/time" type Return = PageReturn type ScheduleRoute = Extract export const OVERRIDE_DIALOG_ID = "schedule-override" +export const DELETE_OVERRIDE_DIALOG_ID = "schedule-delete-override" /** The calendar shows one week of the schedule zone's days, from today. */ export const CALENDAR_DAYS = 7 @@ -50,6 +51,19 @@ export const rangeOf = (model: Model): Option.Option> => + Option.map(rangeOf(model), ({ fromMs, toMs }) => ({ + fromMs: fromMs - SHIFT_READ_MARGIN_MS, + toMs: toMs + SHIFT_READ_MARGIN_MS, + })) + export const init = (route: ScheduleRoute, context: PageContext): Return => { const scheduleId = Option.getOrNull(scheduleIdFromUrl(route.scheduleId)) const model: Model = { @@ -63,12 +77,14 @@ export const init = (route: ScheduleRoute, context: PageContext): Return => { members: null, grants: [], overrideDialog: Dialog.init(OVERRIDE_DIALOG_ID, "dialog"), - overrideDraft: { userId: "", startsAt: "", endsAt: "" }, + overrideDraft: { userId: "", startsAt: "", endsAt: "", startMs: null, endMs: null }, isShowingOverrideErrors: false, isCreatingOverride: false, overrideError: null, overrideOpenCount: 0, deletingOverrideId: null, + confirmDeleteOverrideId: null, + deleteOverrideDialog: Dialog.init(DELETE_OVERRIDE_DIALOG_ID, "alert-dialog"), } const common = [ ReadScheduleClock(), @@ -94,15 +110,22 @@ export const init = (route: ScheduleRoute, context: PageContext): Return => { } } -/** The shifts of the range on screen; a new range or a write reads them again. */ +/** + * The shifts of the range on screen; a new range or a write reads them again. A tick's `Load` + * leaves a failure on screen: only Try again asks again. + */ const requestShifts = (model: Model, context: PageContext, mode: Keyed.ReadMode): Return => { const scheduleId = model.scheduleId - return Option.match(rangeOf(model), { + return Option.match(readRangeOf(model), { onNone: () => ({ model }), onSome: ({ fromMs, toMs }) => { if (scheduleId === null) return { model } const key = requestKey(context.orgId, "oncallSchedules.shifts", { id: scheduleId, fromMs, toMs }) - return Option.match(Keyed.requestFor(model.shifts, key, mode), { + const requested = + mode === "Supersede" + ? Keyed.requestFor(model.shifts, key, mode) + : Keyed.requestKeepingFailure(model.shifts, key, mode) + return Option.match(requested, { onNone: () => ({ model }), onSome: (shifts) => ({ model: { ...model, shifts }, @@ -113,18 +136,55 @@ const requestShifts = (model: Model, context: PageContext, mode: Keyed.ReadMode) }) } +/** The schedule read again: its header's "on call now" moves with a handoff or an override. */ +const refreshSchedule = (model: Model, context: PageContext): Return => { + const scheduleId = model.scheduleId + if (scheduleId === null) return { model } + const schedule = Keyed.supersede( + model.schedule, + requestKey(context.orgId, "oncallSchedules.retrieve", { id: scheduleId }), + ) + return { model: { ...model, schedule }, commands: [FetchSchedule({ key: schedule.key, scheduleId })] } +} + +/** An override write: the overrides, the shifts and the schedule's "on call now" are read again. */ const refreshOverrides = (model: Model, context: PageContext): Return => { const scheduleId = model.scheduleId if (scheduleId === null) return { model } const key = requestKey(context.orgId, "oncallSchedules.listOverrides", { id: scheduleId }) const overrides = Keyed.supersede(model.overrides, key) const shifts = requestShifts({ ...model, overrides }, context, "Supersede") + const schedule = refreshSchedule(shifts.model, context) return { - model: shifts.model, - commands: [FetchOverrides({ key: overrides.key, scheduleId }), ...(shifts.commands ?? [])], + model: schedule.model, + commands: [ + FetchOverrides({ key: overrides.key, scheduleId }), + ...(shifts.commands ?? []), + ...(schedule.commands ?? []), + ], } } +/** With nobody on call there is no handoff to wait for, so the header is read again every 5 minutes. */ +const SCHEDULE_MAX_AGE_MS = 5 * 60_000 + +/** The header's shift has handed off, or a gap has gone unchecked too long. */ +export const isScheduleStale = (model: Model, nowMs: number): boolean => + AsyncData.isSuccess(model.schedule.data) && + Option.exists(scheduleOf(model), (schedule) => + Option.match(Option.fromNullishOr(schedule.on_call_now), { + onSome: (now) => isoMs(now.ends_at) <= nowMs, + onNone: () => nowMs - (model.schedule.updatedAtMs ?? nowMs) >= SCHEDULE_MAX_AGE_MS, + }), + ) + +const clockRead = (model: Model, context: PageContext, nowMs: number): Return => { + const shifts = requestShifts({ ...model, nowMs }, context, "Load") + if (!isScheduleStale(shifts.model, nowMs)) return shifts + const schedule = refreshSchedule(shifts.model, context) + return { model: schedule.model, commands: [...(shifts.commands ?? []), ...(schedule.commands ?? [])] } +} + const decodeUserId = Schema.decodeUnknownOption(UserId) /** What is wrong with the override form, or none. */ @@ -133,8 +193,8 @@ export const overrideIssue = ( timeZone: string, nowMs: number, ): Option.Option => { - const start = localInputMs(draft.startsAt, timeZone) - const end = localInputMs(draft.endsAt, timeZone) + const start = draft.startMs === null ? localInputMs(draft.startsAt, timeZone) : Option.some(draft.startMs) + const end = draft.endMs === null ? localInputMs(draft.endsAt, timeZone) : Option.some(draft.endMs) if (Option.isNone(decodeUserId(draft.userId))) return Option.some("Pick who covers the shift") if (Option.isNone(start) || Option.isNone(end)) return Option.some("Pick when the override starts and ends") @@ -154,8 +214,12 @@ const payloadOf = ( : Option.flatMap(decodeUserId(draft.userId), (user_id) => Option.flatMap( Option.all([ - localInputMs(draft.startsAt, timeZone), - localInputMs(draft.endsAt, timeZone), + draft.startMs === null + ? localInputMs(draft.startsAt, timeZone) + : Option.some(draft.startMs), + draft.endMs === null + ? localInputMs(draft.endsAt, timeZone) + : Option.some(draft.endMs), ]), ([start, end]) => Option.some({ @@ -177,22 +241,26 @@ const liftDialog = (model: Model, step: ReturnType): Retur const openOverride = (model: Model, startMs: number, endMs: number): Return => Option.match(scheduleOf(model), { onNone: () => ({ model }), - onSome: (schedule) => - liftDialog( + onSome: (schedule) => { + // An override cannot lie wholly in the past: one over a running shift starts now. + const fromMs = Math.max(startMs, model.nowMs ?? startMs) + return liftDialog( { ...model, overrideDraft: { userId: "", - // An override cannot lie wholly in the past: one over a running shift starts now. - startsAt: localInputOf(Math.max(startMs, model.nowMs ?? startMs), schedule.time_zone), + startsAt: localInputOf(fromMs, schedule.time_zone), endsAt: localInputOf(endMs, schedule.time_zone), + startMs: fromMs, + endMs, }, isShowingOverrideErrors: false, overrideError: null, overrideOpenCount: model.overrideOpenCount + 1, }, Dialog.open(model.overrideDialog), - ), + ) + }, }) const HOUR_MS = 3_600_000 @@ -216,6 +284,18 @@ const createOverride = (model: Model): Return => { }) } +/** Runs the delete confirmation's update; `Closed` forgets the target. */ +const updateDeleteDialog = (model: Model, step: ReturnType): Return => ({ + model: { + ...model, + deleteOverrideDialog: step.model, + confirmDeleteOverrideId: step.outMessage?._tag === "Closed" ? null : model.confirmDeleteOverrideId, + }, + commands: FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotDeleteOverrideDialogMessage({ message }), + ), +}) + const toast = (type: "success" | "error", title: string, description = "") => PageOutMessage.RequestedToast({ type, title, description }) @@ -224,9 +304,14 @@ const moveWeek = (model: Model, context: PageContext, weekOffset: number): Retur export const update = (model: Model, message: Message, context: PageContext): Return => Message.match(message, { - ReadClock: ({ nowMs }) => requestShifts({ ...model, nowMs }, context, "Load"), - CompletedFetchSchedule: ({ key, result }) => - requestShifts({ ...model, schedule: Keyed.accept(model.schedule, key, result) }, context, "Load"), + ReadClock: ({ nowMs }) => clockRead(model, context, nowMs), + CompletedFetchSchedule: ({ key, readAtMs, result }) => + requestShifts( + { ...model, schedule: Keyed.accept(model.schedule, key, result, readAtMs) }, + context, + "Load", + ), + ClickedRetryShifts: () => requestShifts(model, context, "Supersede"), CompletedFetchShifts: ({ key, result }) => ({ model: { ...model, shifts: Keyed.accept(model.shifts, key, result) }, }), @@ -236,22 +321,12 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedFetchMembers: ({ result }) => Result.match(result, { onSuccess: (members) => ({ model: { ...model, members } }), - onFailure: () => ({ model: { ...model, members: [] } }), + // A failed read keeps names neutral ("Member") rather than calling everyone a former member. + onFailure: () => ({ model }), }), CompletedFetchSession: ({ orgId, grants }) => orgId === context.orgId ? { model: { ...model, grants } } : { model }, - ClickedRetry: () => { - const scheduleId = model.scheduleId - if (scheduleId === null) return { model } - const schedule = Keyed.supersede( - model.schedule, - requestKey(context.orgId, "oncallSchedules.retrieve", { id: scheduleId }), - ) - return { - model: { ...model, schedule }, - commands: [FetchSchedule({ key: schedule.key, scheduleId })], - } - }, + ClickedRetry: () => refreshSchedule(model, context), ClickedPreviousWeek: () => moveWeek(model, context, model.weekOffset - 1), ClickedNextWeek: () => moveWeek(model, context, model.weekOffset + 1), ClickedThisWeek: () => moveWeek(model, context, 0), @@ -270,10 +345,10 @@ export const update = (model: Model, message: Message, context: PageContext): Re model: { ...model, overrideDraft: { ...model.overrideDraft, userId: value } }, }), ChangedOverrideStart: ({ value }) => ({ - model: { ...model, overrideDraft: { ...model.overrideDraft, startsAt: value } }, + model: { ...model, overrideDraft: { ...model.overrideDraft, startsAt: value, startMs: null } }, }), ChangedOverrideEnd: ({ value }) => ({ - model: { ...model, overrideDraft: { ...model.overrideDraft, endsAt: value } }, + model: { ...model, overrideDraft: { ...model.overrideDraft, endsAt: value, endMs: null } }, }), ClickedCreateOverride: () => createOverride(model), CompletedCreateOverride: ({ openCount, result }) => @@ -297,24 +372,39 @@ export const update = (model: Model, message: Message, context: PageContext): Re }), }), ClickedDeleteOverride: ({ overrideId }) => - model.deletingOverrideId !== null || model.scheduleId === null + model.deletingOverrideId !== null || !canManage(model) + ? { model } + : updateDeleteDialog( + { ...model, confirmDeleteOverrideId: overrideId }, + Dialog.open(model.deleteOverrideDialog), + ), + ClickedConfirmDeleteOverride: () => { + const overrideId = model.confirmDeleteOverrideId + return overrideId === null || model.deletingOverrideId !== null || model.scheduleId === null ? { model } : { model: { ...model, deletingOverrideId: overrideId }, commands: [DeleteOverride({ scheduleId: model.scheduleId, overrideId })], - }, + } + }, + GotDeleteOverrideDialogMessage: ({ message: child }) => + // The dialog ignores dismissal while the delete is in flight. + model.deletingOverrideId !== null + ? { model } + : updateDeleteDialog(model, Dialog.update(model.deleteOverrideDialog, child)), CompletedDeleteOverride: ({ result }) => { const settled = { ...model, deletingOverrideId: null } - return Result.match(result, { - onSuccess: (): Return => ({ - ...refreshOverrides(settled, context), - outMessage: toast("success", "Override deleted"), - }), - onFailure: (failure): Return => ({ - model: settled, - outMessage: toast("error", "Couldn't delete the override", failure.message), + const closed = updateDeleteDialog(settled, Dialog.close(settled.deleteOverrideDialog)) + // Either way the list is read again: a 404 means someone else already removed it. + const refreshed = refreshOverrides(closed.model, context) + return { + model: refreshed.model, + commands: [...(closed.commands ?? []), ...(refreshed.commands ?? [])], + outMessage: Result.match(result, { + onSuccess: () => toast("success", "Override deleted"), + onFailure: (failure) => toast("error", "Couldn't delete the override", failure.message), }), - }) + } }, }) diff --git a/apps/web/src/page/alerts/schedule/view.ts b/apps/web/src/page/alerts/schedule/view.ts index 8898cd56dc..d7a84d6ec2 100644 --- a/apps/web/src/page/alerts/schedule/view.ts +++ b/apps/web/src/page/alerts/schedule/view.ts @@ -5,6 +5,7 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" import { borderedBox } from "@maple/views/ui/bordered-box" import { button, linkButton } from "@maple/views/ui/button" +import { confirmDialog } from "@maple/views/ui/confirm-dialog" import { errorState } from "@maple/views/ui/error-state" import { fieldErrorText } from "@maple/views/ui/field" import { formDialog } from "@maple/views/ui/form-dialog" @@ -27,7 +28,15 @@ import { editScheduleHref, formatShiftEdge, oncallTabHref } from "../shared/onca import { memberName, memberOf, type OrgMember } from "../shared/org-members" import { Message } from "./message" import { canManage, type Model } from "./model" -import { OVERRIDE_DIALOG_ID, overrideIssue, overridesOf, rangeOf, scheduleOf } from "./update" +import { + DELETE_OVERRIDE_DIALOG_ID, + OVERRIDE_DIALOG_ID, + overrideIssue, + overridesOf, + rangeOf, + readRangeOf, + scheduleOf, +} from "./update" import { isoMs } from "../shared/time" // `/alerts/schedules/$scheduleId`: who is on call now, the week's calendar (each layer and the @@ -83,7 +92,7 @@ const timelineRows = ( label: "Final schedule", detail: "Who gets paged", bars: Arr.map(shifts.final, (shift, index) => barOf(model, schedule, shift, `final-${index}`, true)), - emptyText: "Nobody on call this week", + emptyText: "Nobody on call in this range", }, ...Arr.reverse( Arr.map(schedule.layers, (layer, index): TimelineRow => ({ @@ -93,7 +102,7 @@ const timelineRows = ( bars: Arr.map(shifts.layers[index] ?? [], (shift, at) => barOf(model, schedule, shift, `layer-${index}-${at}`, false), ), - emptyText: "Not active this week", + emptyText: "Not active in this range", })), ), ] @@ -108,27 +117,36 @@ const weekLabel = (fromMs: number, toMs: number, timeZone: string): string => { const calendarSection = (h: H, model: Model, schedule: V2OnCallSchedule, viewerZone: string): Html => { const range = rangeOf(model) const nowMs = model.nowMs ?? 0 + // Shifts kept from another week (a week's read still in flight) draw as the skeleton, not blank rows. const timeline = (fromMs: number, toMs: number, shifts: V2OnCallShifts): Html => - shiftTimeline(h, { - fromMs, - toMs, - timeZone: schedule.time_zone, - nowMs, - rows: timelineRows(model, schedule, shifts), - ariaLabel: `Shifts for ${schedule.name}`, - legend: [ - { tone: "primary", label: "On call" }, - { tone: "info", label: "Override" }, - { tone: "neutral", label: "Layer shift" }, - ], - }) + !Option.exists(readRangeOf(model), (read) => isoMs(shifts.from) === read.fromMs) + ? skeleton(h, "h-40 w-full") + : shiftTimeline(h, { + fromMs, + toMs, + timeZone: schedule.time_zone, + nowMs, + rows: timelineRows(model, schedule, shifts), + ariaLabel: `Shifts for ${schedule.name}`, + nowLabel: `Now, ${formatShiftEdge(nowMs, schedule.time_zone)}`, + legend: [ + { tone: "primary", label: "On call" }, + { tone: "info", label: "Override" }, + { tone: "neutral", label: "Layer shift" }, + ], + }) const body = Option.match(range, { onNone: () => skeleton(h, "h-40 w-full"), onSome: ({ fromMs, toMs }) => AsyncData.match(model.shifts.data, { onIdle: () => skeleton(h, "h-40 w-full"), onLoading: () => skeleton(h, "h-40 w-full"), - onFailure: (failure) => errorState(h, { failure, title: "Couldn't load the shifts" }), + onFailure: (failure) => + errorState(h, { + failure, + title: "Couldn't load the shifts", + onRetry: Message.ClickedRetryShifts(), + }), onRefreshing: (shifts) => timeline(fromMs, toMs, shifts), onStale: ({ data }) => timeline(fromMs, toMs, data), onSuccess: (shifts) => timeline(fromMs, toMs, shifts), @@ -325,6 +343,32 @@ const overrideDialog = (h: H, model: Model, schedule: V2OnCallSchedule): Html => }) } +/** "Delete {person}'s override?": the layers decide that window again. */ +const deleteOverrideDialog = (h: H, model: Model, schedule: V2OnCallSchedule): Html => { + const target = Arr.findFirst( + overridesOf(model), + (override) => override.id === model.confirmDeleteOverrideId, + ) + const name = Option.getOrElse( + Option.map(target, (override) => `${memberName(model.members, override.user_id)}'s override`), + () => "this override", + ) + return confirmDialog(h, { + slotId: DELETE_OVERRIDE_DIALOG_ID, + model: model.deleteOverrideDialog, + toParentMessage: (message) => Message.GotDeleteOverrideDialogMessage({ message }), + title: `Delete ${name}?`, + description: Option.match(target, { + onNone: () => "The layers decide who is on call again.", + onSome: (override) => + `The layers decide who is on call again from ${rangeText(isoMs(override.starts_at), isoMs(override.ends_at), schedule.time_zone)}.`, + }), + confirmLabel: "Delete override", + isPending: model.deletingOverrideId !== null, + onConfirm: Message.ClickedConfirmDeleteOverride(), + }) +} + const page = (h: H, model: Model, viewerZone: string): Html => { const notFound = dashboardPage(h, {}, [ resourceNotFound(h, { @@ -405,6 +449,7 @@ const page = (h: H, model: Model, viewerZone: string): Html => { ], ), overrideDialog(h, model, schedule), + deleteOverrideDialog(h, model, schedule), ], ), }) diff --git a/apps/web/src/page/alerts/shared/oncall-test-fixtures.ts b/apps/web/src/page/alerts/shared/oncall-test-fixtures.ts index 84c341354c..349bd45537 100644 --- a/apps/web/src/page/alerts/shared/oncall-test-fixtures.ts +++ b/apps/web/src/page/alerts/shared/oncall-test-fixtures.ts @@ -60,7 +60,7 @@ const wallClock = (ms: number, timeZone: string, dayOffset: number, hour: number return zonedPartsToEpochMs({ ...parts, day: parts.day + dayOffset, hour, minute: 0, second: 0 }, timeZone) } -const DAYS = Arr.range(-8, 16) +const DAYS = Arr.range(-8, 50) const DAY_MS = 86_400_000 /** Whose turn a shift starting at `startMs` is: counted from the epoch, so any range agrees. */ From a441386f1790091601e43a91ff05f75e56ca3401 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 15:14:09 +0100 Subject: [PATCH 57/98] fix(alerts): schedule form draws layers top first and sends saved starts back exactly --- .../web/src/page/alerts/schedule-form/form.ts | 14 ++- .../src/page/alerts/schedule-form/message.ts | 2 + .../schedule-form/schedule-form.scene.test.ts | 104 +++++++++++++++++- .../src/page/alerts/schedule-form/update.ts | 26 ++++- .../web/src/page/alerts/schedule-form/view.ts | 44 ++++++-- 5 files changed, 172 insertions(+), 18 deletions(-) diff --git a/apps/web/src/page/alerts/schedule-form/form.ts b/apps/web/src/page/alerts/schedule-form/form.ts index 974e9845bb..d08691f3a6 100644 --- a/apps/web/src/page/alerts/schedule-form/form.ts +++ b/apps/web/src/page/alerts/schedule-form/form.ts @@ -45,6 +45,8 @@ export const LayerDraft = Schema.Struct({ lengthHours: Schema.String, /** `YYYY-MM-DDTHH:MM` on the schedule zone's wall clock (a `datetime-local` value). */ startsAt: Schema.String, + /** The saved start, sent back exactly (seconds, a repeated DST hour) until its text is edited. */ + startsAtMs: Schema.NullOr(Schema.Number), restrictions: Schema.Array(RestrictionDraft), }) export type LayerDraft = typeof LayerDraft.Type @@ -74,7 +76,7 @@ const pad = (n: number) => Str.padStart(2, "0")(String(n)) /** `ms` as a `datetime-local` value on `timeZone`'s wall clock. */ export const localInputOf = (ms: number, timeZone: string): string => { const parts = zonedDateParts(ms, timeZone) - return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}T${pad(parts.hour)}:${pad(parts.minute)}` + return `${Str.padStart(4, "0")(String(parts.year))}-${pad(parts.month)}-${pad(parts.day)}T${pad(parts.hour)}:${pad(parts.minute)}` } const LOCAL_INPUT = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/u @@ -111,6 +113,7 @@ export const emptyLayer = (key: number, index: number, nowMs: number, timeZone: handoffTime: "09:00", lengthHours: "12", startsAt: defaultStart(nowMs, timeZone), + startsAtMs: null, restrictions: [], }) @@ -142,6 +145,7 @@ export const formFromSchedule = (schedule: V2OnCallSchedule): Form => { handoffTime: rotation.kind === "custom" ? "09:00" : rotation.handoff_time, lengthHours: rotation.kind === "custom" ? String(rotation.length_hours) : "12", startsAt: localInputOf(isoMs(layer.starts_at), schedule.time_zone), + startsAtMs: isoMs(layer.starts_at), restrictions: Arr.map(layer.restrictions ?? [], (restriction, index) => ({ key: `r${layerIndex}x${index}`, weekdays: restriction.weekdays, @@ -201,6 +205,10 @@ const restrictionError = (restriction: RestrictionDraft): Option.Option ? Option.some("Use HH:MM times") : Option.none() +/** The layer's start: the saved instant while untouched, else its text on the zone's clock. */ +const layerStartMs = (layer: LayerDraft, timeZone: string): Option.Option => + layer.startsAtMs === null ? localInputMs(layer.startsAt, timeZone) : Option.some(layer.startsAtMs) + export const layerErrors = (layer: LayerDraft, timeZone: string): LayerErrors => { const name = Str.trim(layer.name) return { @@ -224,7 +232,7 @@ export const layerErrors = (layer: LayerDraft, timeZone: string): LayerErrors => : isLocalTime(layer.handoffTime) ? Option.none() : Option.some("Use an HH:MM handoff time"), - startsAt: Option.isSome(localInputMs(layer.startsAt, timeZone)) + startsAt: Option.isSome(layerStartMs(layer, timeZone)) ? Option.none() : Option.some("Pick when the rotation starts"), restrictions: Arr.map(layer.restrictions, restrictionError), @@ -266,7 +274,7 @@ const restrictionOf = (restriction: RestrictionDraft): Option.Option => - Option.flatMap(localInputMs(layer.startsAt, timeZone), (startMs) => { + Option.flatMap(layerStartMs(layer, timeZone), (startMs) => { const participants = layer.participants return Arr.isReadonlyArrayNonEmpty(participants) ? Option.map( diff --git a/apps/web/src/page/alerts/schedule-form/message.ts b/apps/web/src/page/alerts/schedule-form/message.ts index fff133f99b..922636d3dc 100644 --- a/apps/web/src/page/alerts/schedule-form/message.ts +++ b/apps/web/src/page/alerts/schedule-form/message.ts @@ -21,6 +21,7 @@ export type LayerField = typeof LayerField.Type export const Message = defineMessageUnion({ ReadDefaults: { timeZone: Schema.String, nowMs: Schema.Number, zones: Schema.Array(Schema.String) }, CompletedFetchExisting: { key: Schema.String, result: outcome(V2OnCallSchedule) }, + ClickedRetryExisting: {}, CompletedFetchMembers: { result: Schema.Result(Schema.Array(OrgMember), Schema.String) }, CompletedFetchSession: { orgId: Schema.String, grants: Schema.Array(Policy.PermissionName) }, ChangedName: { value: Schema.String }, @@ -28,6 +29,7 @@ export const Message = defineMessageUnion({ GotZoneMessage: { message: Combobox.Message }, ClickedAddLayer: {}, ClickedRemoveLayer: { key: Schema.String }, + /** `up` raises the layer over the one above it, as the form draws them top first. */ ClickedMoveLayer: { key: Schema.String, direction: Direction }, ChangedLayerText: { key: Schema.String, field: LayerField, value: Schema.String }, PickedRotation: { key: Schema.String, rotation: RotationKind }, diff --git a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts index 3bd89764ce..07cac6c86b 100644 --- a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts +++ b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts @@ -1,6 +1,6 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" -import { UserId } from "@maple/domain/http" +import { OnCallScheduleId, UserId } from "@maple/domain/http" import { Option, Result, Schema, Array as Arr } from "effect" import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" @@ -11,9 +11,10 @@ import { ONCALL_NOW, oncallMembers, schedulePublicId, + scheduleUuid, } from "../shared/oncall-test-fixtures" import { ORG } from "../shared/test-rows" -import { formFromSchedule, localInputMs, payloadOf } from "./form" +import { formFromSchedule, localInputMs, localInputOf, payloadOf } from "./form" import { Message } from "./message" import type { Model } from "./model" import { init, update } from "./update" @@ -148,4 +149,103 @@ describe("schedule form", () => { expectView(text("Omar Haddad is no longer a member of this organization.")).toExist(), ) }) + + test("layers draw top first, and Move up on the bottom layer makes it the top one in the saved payload", () => { + const model = run(ready(), [ + Message.ChangedName({ value: "Checkout" }), + addPerson("l0", 0), + Message.ClickedAddLayer(), + addPerson("l1", 1), + Message.ChangedLayerText({ key: "l1", field: "name", value: "Nights" }), + ]) + scene( + { update: pageUpdate, view }, + given(model), + expectView(role("button", { name: "Move layer 1 up" })).toExist(), + expectView(text("Layer 2, on top")).toExist(), + expectView(text("Layer 1, at the bottom")).toExist(), + ) + const raised = run(model, [Message.ClickedMoveLayer({ key: "l0", direction: "up" })]) + expect( + Option.map(payloadOf(raised.form), (payload) => Arr.map(payload.layers, (layer) => layer.name)), + ).toEqual(Option.some(["Nights", "Primary"])) + }) + + test("a window's midnight note waits until both times are complete", () => { + const model = run(ready(), [Message.ClickedAddRestriction({ key: "l0" })]) + scene({ update: pageUpdate, view }, given(model), expectView(text("Ends the next morning")).toExist()) + scene( + { update: pageUpdate, view }, + given( + run(model, [ + Message.ChangedRestrictionTime({ + key: "l0", + restrictionKey: "r1", + edge: "end", + value: "", + }), + ]), + ), + expectView(text("Ends the next morning")).not.toExist(), + expectView(text("Same day")).not.toExist(), + ) + }) + + test("an edit sends a layer's saved start back exactly, seconds and a repeated DST hour included, until its text changes", () => { + const schedule = checkoutSchedule(ONCALL_NOW) + const repeatedHour = "2026-10-25T01:30:15.000Z" // 02:30:15 CET, after Berlin fell back + const edited = { + ...schedule, + layers: Arr.map(schedule.layers, (layer, index) => + index === 0 ? { ...layer, starts_at: repeatedHour } : layer, + ), + } + const base = ready() + const loaded = run({ ...base, editingId: Schema.decodeSync(OnCallScheduleId)(scheduleUuid(1)) }, []) + const filled = { ...loaded, form: formFromSchedule(edited) } + expect(Option.map(payloadOf(filled.form), (payload) => payload.layers[0].starts_at)).toEqual( + Option.some(repeatedHour), + ) + const retyped = run(filled, [ + Message.ChangedLayerText({ key: "l0", field: "startsAt", value: "2026-10-26T09:00" }), + ]) + expect(Option.map(payloadOf(retyped.form), (payload) => payload.layers[0].starts_at)).toEqual( + Option.some("2026-10-26T08:00:00.000Z"), + ) + }) + + test("a start before year 1000 keeps four year digits", () => { + expect(localInputOf(Date.UTC(999, 0, 2, 9, 0), "UTC")).toBe("0999-01-02T09:00") + }) + + test("a failed load of the edited schedule offers Try again, which reads it again", () => { + const entered = init( + { _tag: "AlertsScheduleNew", search: { scheduleId: schedulePublicId(1) } }, + context, + ) + const failed = run(entered.model, [ + Message.ReadDefaults({ timeZone: "Europe/Berlin", nowMs: ONCALL_NOW, zones: ZONES }), + Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), + Message.CompletedFetchExisting({ + key: entered.model.existing.key ?? "", + result: Result.fail({ + _tag: "@maple/http/errors/AlertPersistenceError", + type: "api_error", + code: "persistence_error", + title: "Couldn't read", + message: "Couldn't read the schedule", + retryable: false, + recovery: "retry", + }), + }), + ]) + scene( + { update: pageUpdate, view }, + given(failed), + expectView(role("button", { name: /Try again/u })).toExist(), + ) + expect(names(pageUpdate(failed, Message.ClickedRetryExisting()).commands)).toContain( + "FetchScheduleFormSchedule", + ) + }) }) diff --git a/apps/web/src/page/alerts/schedule-form/update.ts b/apps/web/src/page/alerts/schedule-form/update.ts index 4c1b11ef82..8b43f9ba79 100644 --- a/apps/web/src/page/alerts/schedule-form/update.ts +++ b/apps/web/src/page/alerts/schedule-form/update.ts @@ -204,7 +204,8 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedFetchMembers: ({ result }) => Result.match(result, { onSuccess: (members) => ({ model: { ...model, members, membersError: null } }), - onFailure: (error) => ({ model: { ...model, members: [], membersError: error } }), + // Members stay unknown (names read "Member"), and the picker says why it is empty. + onFailure: (error) => ({ model: { ...model, members: null, membersError: error } }), }), CompletedFetchSession: ({ orgId, grants }) => orgId === context.orgId ? { model: { ...model, grants, isSessionLoaded: true } } : { model }, @@ -232,13 +233,32 @@ export const update = (model: Model, message: Message, context: PageContext): Re ...model.form, layers: Arr.filter(model.form.layers, (layer) => layer.key !== key), }), + // Layers are stored bottom first and drawn top first: "up" raises a layer over the next one. ClickedMoveLayer: ({ key, direction }) => withForm(model, { ...model.form, - layers: swapIndex(model.form.layers, (layer) => layer.key === key, direction), + layers: swapIndex( + model.form.layers, + (layer) => layer.key === key, + direction === "up" ? "down" : "up", + ), }), + ClickedRetryExisting: () => { + const editingId = model.editingId + if (editingId === null) return { model } + const existing = Keyed.supersede( + model.existing, + requestKey(context.orgId, "oncallSchedules.retrieve", { id: editingId }), + ) + return { + model: { ...model, existing }, + commands: [FetchExistingSchedule({ key: existing.key, scheduleId: editingId })], + } + }, ChangedLayerText: ({ key, field, value }) => - editLayer(model, key, (layer) => ({ ...layer, [field]: value })), + field === "startsAt" + ? editLayer(model, key, (layer) => ({ ...layer, startsAt: value, startsAtMs: null })) + : editLayer(model, key, (layer) => ({ ...layer, [field]: value })), PickedRotation: ({ key, rotation }) => editLayer(model, key, (layer) => ({ ...layer, rotation })), PickedHandoffWeekday: ({ key, weekday }) => editLayer(model, key, (layer) => ({ ...layer, handoffWeekday: weekday })), diff --git a/apps/web/src/page/alerts/schedule-form/view.ts b/apps/web/src/page/alerts/schedule-form/view.ts index 1048f83c5a..630aed9798 100644 --- a/apps/web/src/page/alerts/schedule-form/view.ts +++ b/apps/web/src/page/alerts/schedule-form/view.ts @@ -1,4 +1,5 @@ -import { Option, Array as Arr } from "effect" +import { OnCallLocalTime } from "@maple/domain/http" +import { Option, Schema, Array as Arr } from "effect" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { failureMessage } from "@maple/views/data/load-failure" @@ -55,6 +56,8 @@ import { participantMenuId } from "./update" type H = HtmlBuilder +const isLocalTime = Schema.is(OnCallLocalTime) + const FORM_WIDTH = "max-w-3xl" /** A field's error line once a save was tried. */ @@ -303,7 +306,8 @@ const rotationField = (h: H, layer: LayerDraft): Html => { const restrictionRow = (h: H, layer: LayerDraft, restriction: RestrictionDraft): Html => { const key = layer.key const restrictionKey = restriction.key - const crossesMidnight = restriction.endTime <= restriction.startTime + const isComplete = isLocalTime(restriction.startTime) && isLocalTime(restriction.endTime) + const crossesMidnight = isComplete && restriction.endTime <= restriction.startTime return vstack(h, { gap: "2", className: "rounded-md border p-3", attributes: [h.Key(restrictionKey)] }, [ hstack(h, { gap: "2", justify: "between", wrap: true }, [ multiSegmentedSelect(h, { @@ -347,9 +351,14 @@ const restrictionRow = (h: H, layer: LayerDraft, restriction: RestrictionDraft): onInput: (value) => Message.ChangedRestrictionTime({ key, restrictionKey, edge: "end", value }), }), - typography(h, { size: "xs", tone: "muted", className: "pb-2" }, [ - crossesMidnight ? "Ends the next morning" : "Same day", - ]), + // A time still being typed says nothing about midnight yet. + ...(isComplete + ? [ + typography(h, { size: "xs", tone: "muted", className: "pb-2" }, [ + crossesMidnight ? "Ends the next morning" : "Same day", + ]), + ] + : []), ]), ]) } @@ -381,6 +390,16 @@ const restrictionsField = (h: H, model: Model, layer: LayerDraft): Html => { } /** One layer: its name and order, its people, how it hands off, from when, and its windows. */ +/** "Layer 2, on top": layers are numbered bottom first, and drawn top first. */ +export const layerLabel = (index: number, count: number): string => + count === 1 + ? "Layer 1" + : index === count - 1 + ? `Layer ${index + 1}, on top` + : index === 0 + ? "Layer 1, at the bottom" + : `Layer ${index + 1}` + const layerBlock = (h: H, model: Model, layer: LayerDraft, index: number): Html => { const key = layer.key const count = model.form.layers.length @@ -389,7 +408,7 @@ const layerBlock = (h: H, model: Model, layer: LayerDraft, index: number): Html hstack(h, { gap: "2", justify: "between", align: "end" }, [ textInput(h, { id: `${key}-name`, - label: index === 0 ? `Layer ${index + 1}, at the bottom` : `Layer ${index + 1}`, + label: layerLabel(index, count), value: layer.name, className: "min-w-0 max-w-xs flex-1", onInput: layerText(key, "name"), @@ -399,7 +418,7 @@ const layerBlock = (h: H, model: Model, layer: LayerDraft, index: number): Html h, { label: `Move layer ${index + 1} up`, - isDisabled: index === 0, + isDisabled: index === count - 1, attributes: [h.OnClick(Message.ClickedMoveLayer({ key, direction: "up" }))], }, [icon(h, "ArrowUpIcon")], @@ -408,7 +427,7 @@ const layerBlock = (h: H, model: Model, layer: LayerDraft, index: number): Html h, { label: `Move layer ${index + 1} down`, - isDisabled: index === count - 1, + isDisabled: index === 0, attributes: [h.OnClick(Message.ClickedMoveLayer({ key, direction: "down" }))], }, [icon(h, "ArrowDownIcon")], @@ -444,7 +463,8 @@ const layerBlock = (h: H, model: Model, layer: LayerDraft, index: number): Html const layersSection = (h: H, model: Model): Html => formSection(h, { title: "Layers", hint: "Later layers sit on top where they have someone on call" }, [ - ...Arr.map(model.form.layers, (layer, index) => layerBlock(h, model, layer, index)), + // Top layer first, so "up" reads as "wins over the layer below". + ...Arr.reverse(Arr.map(model.form.layers, (layer, index) => layerBlock(h, model, layer, index))), ...shown( h, model, @@ -516,7 +536,11 @@ export const view = Submodel.defineView((model, }) if (isEdit && AsyncData.isFailure(model.existing.data)) return dashboardPage(h, header, [ - errorState(h, { failure: model.existing.data.error, title: "Couldn't load the schedule" }), + errorState(h, { + failure: model.existing.data.error, + title: "Couldn't load the schedule", + onRetry: Message.ClickedRetryExisting(), + }), ]) if (!model.isReady || (isEdit && !AsyncData.isSuccess(model.existing.data))) return dashboardPage(h, header, [skeleton(h, "h-24 w-full"), skeleton(h, "h-80 w-full")]) From 772d9688bfc5c9188a8d708bc31420720f5c5a04 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 15:14:09 +0100 Subject: [PATCH 58/98] fix(alerts): hub names schedules neutrally until they load, reads members only where shown, and refreshes schedules after chain writes --- .../src/page/alerts/escalation-dialog/view.ts | 6 +-- .../page/alerts/hub/hub-oncall.scene.test.ts | 50 +++++++++++++++---- apps/web/src/page/alerts/hub/model.ts | 2 + apps/web/src/page/alerts/hub/update.ts | 25 +++++++--- apps/web/src/page/alerts/hub/view-chains.ts | 20 ++++---- apps/web/src/page/alerts/hub/view.ts | 3 +- .../page/alerts/shared/escalation-chains.ts | 3 +- 7 files changed, 80 insertions(+), 29 deletions(-) diff --git a/apps/web/src/page/alerts/escalation-dialog/view.ts b/apps/web/src/page/alerts/escalation-dialog/view.ts index 7437302c81..e60d47f1a1 100644 --- a/apps/web/src/page/alerts/escalation-dialog/view.ts +++ b/apps/web/src/page/alerts/escalation-dialog/view.ts @@ -51,8 +51,8 @@ export type ViewInputs = Readonly<{ destinations: ReadonlyArray | null /** The org's members a step can page; `null` until they load. */ members: ReadonlyArray | null - /** The on-call schedules a step can page; empty until they load. */ - schedules: ReadonlyArray + /** The on-call schedules a step can page; `null` until they load. */ + schedules: ReadonlyArray | null }> const nameOr = ( @@ -153,7 +153,7 @@ const addEntries = (inputs: ViewInputs, step: StepDraft): ReadonlyArray ({ + Arr.map(inputs.schedules ?? [], (schedule) => ({ value: `schedule:${schedule.id}`, label: schedule.name, })), diff --git a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts index 255a459c9f..b08d1c3740 100644 --- a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts @@ -1,13 +1,13 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" -import { OnCallScheduleId } from "@maple/domain/http" +import { EscalationChainId, OnCallScheduleId } from "@maple/domain/http" import type { V2OnCallSchedule } from "@maple/domain/http/v2" import { Result, Schema, Array as Arr } from "effect" import * as Menu from "@maple/views/ui/menu" import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" -import { chainDestinations } from "../shared/escalation-test-fixtures" +import { chainDestinations, chainUuid } from "../shared/escalation-test-fixtures" import { SCHEDULE_IN_USE_CODE } from "../shared/oncall-schedules" import { mixedTargetChain, @@ -19,7 +19,7 @@ import { import { incidentRow, ORG, ruleRow } from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" -import { init, scheduleMenuId, update } from "./update" +import { init, routeChanged, scheduleMenuId, update } from "./update" import { view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)(ORG) @@ -36,7 +36,7 @@ const SCHEDULES = populatedSchedules(ONCALL_NOW) const onTab = ( tab: string, grants: ReturnType, - schedules: ReadonlyArray, + schedules: ReadonlyArray | null, ) => { const entered = init({ _tag: "Alerts", search: { tab } }, context) expect(names(entered.commands)).toContain("FetchAlertsHubSchedules") @@ -53,11 +53,15 @@ const onTab = ( key: entered.model.chains.key ?? "", result: Result.succeed([mixedTargetChain(chainDestinations[0]?.id ?? "")]), }), - Message.CompletedFetchSchedules({ - key: entered.model.schedules.key ?? "", - readAtMs: ONCALL_NOW, - result: Result.succeed(schedules), - }), + ...(schedules === null + ? [] + : [ + Message.CompletedFetchSchedules({ + key: entered.model.schedules.key ?? "", + readAtMs: ONCALL_NOW, + result: Result.succeed(schedules), + }), + ]), ], entered.model, (model, message) => pageUpdate(model, message).model, @@ -170,4 +174,32 @@ describe("alerts hub, on-call schedules", () => { "Still paged by Checkout on-call. Remove it from those chains first.", ) }) + + test("before the schedules load, a chain says it pages the on-call rather than a deleted schedule", () => { + scene( + { update: pageUpdate, view }, + given(onTab("routing", ENGINEER, null)), + expectView(text(/then the on-call and Dana Kim after 5m/u)).toExist(), + expectView(text(/a deleted schedule/u)).not.toExist(), + ) + }) + + test("members are read only by the tabs that name people, once", () => { + const rules = init({ _tag: "Alerts", search: { tab: "rules" } }, context) + expect(names(rules.commands)).not.toContain("FetchAlertsHubMembers") + const oncall = routeChanged(rules.model, { _tag: "Alerts", search: { tab: "oncall" } }, context) + expect(names(oncall.commands)).toContain("FetchAlertsHubMembers") + const routing = routeChanged(oncall.model, { _tag: "Alerts", search: { tab: "routing" } }, context) + expect(names(routing.commands)).not.toContain("FetchAlertsHubMembers") + }) + + test("a deleted chain reads the schedules again, whose chain lists named it", () => { + const model = onTab("routing", ENGINEER, SCHEDULES) + const chainId = Schema.decodeSync(EscalationChainId)(chainUuid(1)) + const deleted = pageUpdate( + { ...model, confirmDeleteChainId: chainId, deletingChainId: chainId }, + Message.CompletedDeleteChain({ chainId, result: Result.void }), + ) + expect(names(deleted.commands)).toContain("FetchAlertsHubSchedules") + }) }) diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index 7459b7b294..6070ed3061 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -109,6 +109,8 @@ export const Model = Schema.Struct({ schedules: KeyedSchema(Schema.Array(V2OnCallSchedule), LoadFailure), /** The org's members, to name who is on call; `null` until Clerk answers (or failed). */ members: Schema.NullOr(Schema.Array(OrgMember)), + /** The members were asked for: only the tabs that name people (inbox, On-call, Routing) ask. */ + isMembersRequested: Schema.Boolean, confirmDeleteScheduleId: Schema.NullOr(OnCallScheduleId), deletingScheduleId: Schema.NullOr(OnCallScheduleId), deleteScheduleError: Schema.NullOr(Schema.String), diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index cceac8ab51..e7a030dcf8 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -206,6 +206,14 @@ const refreshDeliveries = (model: Model, context: PageContext) => { return { model: routing.model, commands: [...overview.commands, ...routing.commands] } } +/** Members are read once, on the first tab that names people: the inbox strip, On-call, Routing. */ +const requestMembers = (model: Model): Requested => { + const tab = resolveTab(model.search.tab) + return model.isMembersRequested || (tab !== "inbox" && tab !== "oncall" && tab !== "routing") + ? { model, commands: [] } + : { model: { ...model, isMembersRequested: true }, commands: [FetchHubMembers()] } +} + export const init = (route: HubRoute, context: PageContext): Return => { const model: Model = { search: route.search, @@ -249,6 +257,7 @@ export const init = (route: HubRoute, context: PageContext): Return => { deleteChainDialog: Dialog.init(DELETE_CHAIN_DIALOG_ID, "alert-dialog"), schedules: Keyed.idle(), members: null, + isMembersRequested: false, confirmDeleteScheduleId: null, deletingScheduleId: null, deleteScheduleError: null, @@ -260,12 +269,13 @@ export const init = (route: HubRoute, context: PageContext): Return => { const slos = requestSlos(monitors.model, context, "Load") const chains = requestChains(slos.model, context, "Load") const schedules = requestSchedules(chains.model, context, "Load") + const members = requestMembers(schedules.model) return { - model: schedules.model, + model: members.model, commands: [ ReadHubClock(), FetchHubSession({ orgId: context.orgId }), - FetchHubMembers(), + ...members.commands, ...schedules.commands, ...deliveries.commands, ...routing.commands, @@ -282,14 +292,16 @@ export const routeChanged = (model: Model, route: HubRoute, context: PageContext const slos = requestSlos(monitors.model, context, "Load") const chains = requestChains(slos.model, context, "Load") const schedules = requestSchedules(chains.model, context, "Load") + const members = requestMembers(schedules.model) return { - model: schedules.model, + model: members.model, commands: [ ...routing.commands, ...monitors.commands, ...slos.commands, ...chains.commands, ...schedules.commands, + ...members.commands, ], } } @@ -375,10 +387,11 @@ const liftDestinationDialog = (model: Model, step: ReturnType => Option.getOrElse(AsyncData.getData(model.chains.data), () => []) -/** A write changed the chains: read them again under a new generation. */ +/** A write changed the chains: read them again, and the schedules whose `chain_ids` name them. */ const refreshChains = (model: Model, context: PageContext): Return => { - const step = requestChains(model, context, "Supersede") - return { model: step.model, commands: step.commands } + const chains = requestChains(model, context, "Supersede") + const schedules = requestSchedules(chains.model, context, "Supersede") + return { model: schedules.model, commands: [...chains.commands, ...schedules.commands] } } /** The chain dialog's outcome: a toast, and the list read again. */ diff --git a/apps/web/src/page/alerts/hub/view-chains.ts b/apps/web/src/page/alerts/hub/view-chains.ts index 944d83ca56..46b0003016 100644 --- a/apps/web/src/page/alerts/hub/view-chains.ts +++ b/apps/web/src/page/alerts/hub/view-chains.ts @@ -51,17 +51,19 @@ export const chainRow = ( ): Html => { const summary = chainSummary(chain, nameOfDestination(destinations), { userName: (id) => memberName(model.members, id), + // Before the schedules load the summary says "the on-call"; once loaded, a missing one is deleted. scheduleName: (id) => - Option.getOrElse( - Option.map( - Arr.findFirst( - Option.getOrElse(AsyncData.getData(model.schedules.data), () => []), - (schedule) => schedule.id === id, + Option.match(AsyncData.getData(model.schedules.data), { + onNone: () => undefined, + onSome: (schedules) => + Option.getOrElse( + Option.map( + Arr.findFirst(schedules, (schedule) => schedule.id === id), + (schedule) => schedule.name, + ), + () => "a deleted schedule", ), - (schedule) => schedule.name, - ), - () => "a deleted schedule", - ), + }), }) const ruleNames = chainRuleNames(model, chain) const menuId = chainMenuId(chain.id) diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index 486811ea4e..4b0adb029a 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -350,7 +350,8 @@ export const view = Submodel.defineView((model, id: CHAIN_DIALOG_ID, destinations: model.destinations, members: model.members, - schedules: Option.getOrElse(AsyncData.getData(model.schedules.data), () => []), + // `null` until the schedules load (or after a failed read): names read neutrally. + schedules: Option.getOrNull(AsyncData.getData(model.schedules.data)), }, toParentMessage: (message) => Message.GotChainDialogMessage({ message }), }), diff --git a/apps/web/src/page/alerts/shared/escalation-chains.ts b/apps/web/src/page/alerts/shared/escalation-chains.ts index 1cb8098515..89afc2f824 100644 --- a/apps/web/src/page/alerts/shared/escalation-chains.ts +++ b/apps/web/src/page/alerts/shared/escalation-chains.ts @@ -89,7 +89,8 @@ const repeatPhrase = (count: number): string => /** How a summary names the people and schedules a step pages; without them, generic words. */ export type TargetNames = Readonly<{ userName?: (id: UserId) => string - scheduleName?: (id: OnCallScheduleId) => string + /** `undefined` while the name is not known yet: the summary then says "the on-call". */ + scheduleName?: (id: OnCallScheduleId) => string | undefined }> /** From 767214de2a50e600cdeb8af725501787ea92baed Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 15:14:09 +0100 Subject: [PATCH 59/98] fix(alerts): an unnamed page reads as a member, and a malformed one invents no channel --- .../src/alerts/incident-timeline.test.ts | 21 ++++++++++++++++- .../web-data/src/alerts/incident-timeline.ts | 23 +++++++++++-------- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/packages/web-data/src/alerts/incident-timeline.test.ts b/packages/web-data/src/alerts/incident-timeline.test.ts index 0a34c93edd..11b11b6cba 100644 --- a/packages/web-data/src/alerts/incident-timeline.test.ts +++ b/packages/web-data/src/alerts/incident-timeline.test.ts @@ -261,7 +261,26 @@ describe("paged events", () => { expect(Arr.map(pages, (entry) => [entry.title, entry.detail, entry.tone])).toEqual([ ["Paged Ben Brook by Slack DM", null, "info"], ["Couldn't page Ben Brook by email", "Mailbox full", "crit"], - ["Skipped paging a former member by Discord DM", "Acknowledged or resolved first", "neutral"], + ["Skipped paging a member by Discord DM", "Acknowledged or resolved first", "neutral"], + ]) + }) + + it("a paged event without its page reads as a page to a member, inventing no channel or outcome", () => { + const entries = build( + makeIncident(), + [], + [ + makeActivity({ + id: "evt_tC4d9V79DCDzgbGKhAnffF", + type: "paged", + actor: null, + source: null, + created_at: "2026-10-15T14:20:00.000Z", + }), + ], + ) + expect(Arr.filter(entries, (entry) => Str.startsWith("page:")(entry.key))).toMatchObject([ + { title: "Paged a member", detail: null, tone: "neutral" }, ]) }) }) diff --git a/packages/web-data/src/alerts/incident-timeline.ts b/packages/web-data/src/alerts/incident-timeline.ts index 51cf088e25..63567594a3 100644 --- a/packages/web-data/src/alerts/incident-timeline.ts +++ b/packages/web-data/src/alerts/incident-timeline.ts @@ -94,15 +94,17 @@ function activityEntry(event: V2AlertIncidentEvent): IncidentTimelineEntry { detail: "The incident escalated, so it notified again", } case "paged": - // A paged event without its page is malformed; it reads as an unnamed page. - return pageEntry({ - id: event.id, - at: event.created_at, - name: event.page?.name ?? null, - channel: event.page?.channel ?? "email", - status: event.page?.status ?? "sent", - errorMessage: event.page?.error_message ?? null, - }) + // Without its page the event says only that someone was paged: no channel or outcome invented. + return event.page === undefined + ? { ...base, key: `page:${event.id}`, tone: "neutral", title: "Paged a member", detail: null } + : pageEntry({ + id: event.id, + at: event.created_at, + name: event.page.name, + channel: event.page.channel, + status: event.page.status, + errorMessage: event.page.error_message, + }) } } @@ -212,7 +214,8 @@ export function pageChannelLabel(channel: string): string { /** "Paged Ben Brook by Slack DM"; a failed or skipped page says so and why. */ export function pageEntry(page: IncidentPage): IncidentTimelineEntry { - const who = page.name ?? "a former member" + // The directory could not name them: that is not proof they left. + const who = page.name ?? "a member" const by = pageChannelLabel(page.channel) const sending = page.status === "queued" || page.status === "sending" const title = From f83e7d0dfe3cfcc27b49d759fc8bb0288e81531d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 15:14:09 +0100 Subject: [PATCH 60/98] test(web): sample URLs for the on-call schedule pages --- apps/web/src/view.lazy.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/view.lazy.test.ts b/apps/web/src/view.lazy.test.ts index 8aea4963a0..a8fc0b2bf3 100644 --- a/apps/web/src/view.lazy.test.ts +++ b/apps/web/src/view.lazy.test.ts @@ -44,6 +44,8 @@ const SAMPLE_PATHS = [ "/alerts/uptime/new", "/alerts/slos/new", "/alerts/slos/slo_YofPTrK9782DWwcnXhpcCw", + "/alerts/schedules/new", + "/alerts/schedules/onc_gQtGH2s65G6a7sJmuayXL6", "/alerts/uptime/upm_abc", "/status-pages", "/status-pages/new", From 45143a80daa360c74a24f10db5dbfd23c9aa7315 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 15:15:08 +0100 Subject: [PATCH 61/98] fix(web): a failed or skipped page reads as such in the incident timeline --- .../src/alerts/incident-timeline.test.ts | 54 +++++++++++++++++++ .../web-data/src/alerts/incident-timeline.ts | 17 +++--- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/packages/web-data/src/alerts/incident-timeline.test.ts b/packages/web-data/src/alerts/incident-timeline.test.ts index 3e555d2144..c85634e7cb 100644 --- a/packages/web-data/src/alerts/incident-timeline.test.ts +++ b/packages/web-data/src/alerts/incident-timeline.test.ts @@ -211,6 +211,60 @@ describe("buildIncidentTimeline", () => { expect(entries.at(-1)?.detail).toBe("Last value 8.4% · reminders every 30m while open") }) + it("says what happened to each page an escalation sent", () => { + const entries = build( + makeIncident(), + [], + [ + makeActivity({ + type: "paged", + actor: null, + source: null, + created_at: "2026-10-15T14:21:00.000Z", + page: { + user_id: "user_ben", + name: "Ben Brook", + channel: "email", + status: "sent", + error_message: null, + }, + }), + makeActivity({ + type: "paged", + actor: null, + source: null, + created_at: "2026-10-15T14:22:00.000Z", + page: { + user_id: "user_ben", + name: "Ben Brook", + channel: "slack", + status: "failed", + error_message: "The chat account could not be reached", + }, + }), + makeActivity({ + type: "paged", + actor: null, + source: null, + created_at: "2026-10-15T14:23:00.000Z", + page: { + user_id: "user_ben", + name: "Ben Brook", + channel: "email", + status: "skipped", + error_message: "Not sent: the incident was acknowledged", + }, + }), + ], + ) + expect(Arr.map(entries.slice(1, 4), (entry) => [entry.title, entry.tone])).toEqual([ + ["Paged Ben Brook by email", "info"], + ["Could not page Ben Brook by slack DM", "warn"], + ["Did not page Ben Brook by email", "neutral"], + ]) + expect(entries[2]?.detail).toBe("The chat account could not be reached") + }) + it("keeps the state still in effect last when an ack came after it", () => { const entries = build( makeIncident({ holdReason: "no_data", heldSince: "2026-10-15T14:40:00.000Z" }), diff --git a/packages/web-data/src/alerts/incident-timeline.ts b/packages/web-data/src/alerts/incident-timeline.ts index 2cd4e0fad9..9c09d723c8 100644 --- a/packages/web-data/src/alerts/incident-timeline.ts +++ b/packages/web-data/src/alerts/incident-timeline.ts @@ -95,14 +95,19 @@ function activityEntry(event: V2AlertIncidentEvent): IncidentTimelineEntry { } case "paged": { const page = event.page + const who = page?.name ?? "a member" const channel = page === undefined ? "" : page.channel === "email" ? " by email" : ` by ${page.channel} DM` - return { - ...base, - tone: page?.status === "failed" ? "warn" : "info", - title: `Paged ${page?.name ?? page?.user_id ?? "a member"}${channel}`, - detail: page === undefined || page.status === "sent" ? null : page.status, - } + // The title says what happened to the page, not only that one was attempted. + const outcome = + page?.status === "failed" + ? { tone: "warn" as const, title: `Could not page ${who}${channel}`, pending: false } + : page?.status === "skipped" + ? { tone: "neutral" as const, title: `Did not page ${who}${channel}`, pending: false } + : page?.status === "queued" || page?.status === "sending" + ? { tone: "info" as const, title: `Paging ${who}${channel}`, pending: true } + : { tone: "info" as const, title: `Paged ${who}${channel}`, pending: false } + return { ...base, ...outcome, detail: page?.error_message ?? null } } } } From 7227c4ba8f8239797ce5f416b52c918337f5ee37 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 15:15:11 +0100 Subject: [PATCH 62/98] feat(lab): /lab/oncall shows the override delete confirmation --- apps/web/src/lab/oncall-lab.ts | 18 ++++++++++++++++-- apps/web/src/lab/registry.ts | 2 +- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/web/src/lab/oncall-lab.ts b/apps/web/src/lab/oncall-lab.ts index 36dbaa6362..cdfbaec4dd 100644 --- a/apps/web/src/lab/oncall-lab.ts +++ b/apps/web/src/lab/oncall-lab.ts @@ -1,5 +1,6 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" +import { OnCallOverrideId } from "@maple/domain/http" import type { LoadFailure } from "@maple/views/data/load-failure" import { zonedDayStart } from "@maple/views/ui/shift-timeline" import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" @@ -31,6 +32,7 @@ import { DST_NOW, memberId, mixedTargetChain, + overrideUuid, ONCALL_NOW, oncallMembers, pagedEvents, @@ -57,6 +59,7 @@ export const LabState = Schema.Literals([ "calendar", "calendar-dst", "override-dialog", + "override-delete", "chain-dialog", "incident-paged", ]) @@ -311,7 +314,17 @@ const scheduleFor = (state: LabState): Step => { const isHub = (state: LabState) => state === "schedules" || state === "schedules-empty" || state === "strip" || state === "chain-dialog" const isForm = (state: LabState) => Str.startsWith("editor-")(state) -const isSchedule = (state: LabState) => Str.startsWith("calendar")(state) || state === "override-dialog" +const isSchedule = (state: LabState) => + Str.startsWith("calendar")(state) || Str.startsWith("override-")(state) export const init = (flags: Flags): Return => { const hub = hubFor(flags.state) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 8e0d5be297..2327e71ad1 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -73,7 +73,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/oncall", title: "On-call schedules", description: - "The On-call tab empty and populated, the inbox's who-is-on-call strip, the schedule form new, editing, invalid and failing, the week calendar with overrides and a DST week, the override dialog, the chain dialog with people and schedules, and an incident timeline with pages (`?state=`).", + "The On-call tab empty and populated, the inbox's who-is-on-call strip, the schedule form new, editing, invalid and failing, the week calendar with overrides and a DST week, the override dialog and its delete confirmation, the chain dialog with people and schedules, and an incident timeline with pages (`?state=`).", kind: "lab", session: "none", }, From 29a7171739cb24b3703dbf79651d53eb1a3b7f03 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 15:25:09 +0100 Subject: [PATCH 63/98] fix(alerts): on-call bounds are the real handoffs, not the lookaround edges onCallAt resolves the effective shift without merging across rotation handoffs, so a person holding consecutive shifts ends at the next handoff. A bound past the lookaround snaps to the winning shift's own edge. who_is_on_call reads the next shift at the real end. --- .../__tests__/oncall-schedule-tools.test.ts | 124 ++++++++++-------- apps/ai/src/mcp/tools/who-is-on-call.ts | 46 ++++--- packages/alerting-core/src/oncall.test.ts | 58 +++++++- packages/alerting-core/src/oncall.ts | 116 ++++++++++++---- 4 files changed, 239 insertions(+), 105 deletions(-) diff --git a/apps/ai/src/mcp/tools/__tests__/oncall-schedule-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/oncall-schedule-tools.test.ts index d739346036..f71d5b67ee 100644 --- a/apps/ai/src/mcp/tools/__tests__/oncall-schedule-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/oncall-schedule-tools.test.ts @@ -17,7 +17,6 @@ import { EscalationChainId, OnCallSchedulesListResponse, OnCallShiftsResponse, - ONCALL_LOOKAROUND_MS, OrgId, UserId, type OnCallOverrideCreateRequest, @@ -77,10 +76,28 @@ interface Seen { rejectMember?: boolean /** An escalation chain still pages the schedule. */ inUse?: boolean - /** Ana's shift runs past the 15-day lookaround, so its end is the clamp, not a handoff. */ - longShift?: boolean + /** The effective shifts the service resolves from; defaults to `OVERRIDE_THEN_ANA`. */ + timeline?: ReadonlyArray } +// Ben's evening override, then Ana's layer shift from the next morning. +const OVERRIDE_THEN_ANA: ReadonlyArray = [ + { + userId: "user_ben", + startsAt: "2026-11-12T17:00:00.000Z", + endsAt: "2026-11-13T08:00:00.000Z", + source: { kind: "override", overrideId: OVERRIDE_ID }, + }, + { + userId: "user_ana", + startsAt: "2026-11-13T08:00:00.000Z", + endsAt: "2026-11-16T08:00:00.000Z", + source: { kind: "layer", layerIndex: 0 }, + }, +] + +const timelineOf = (seen: Seen) => seen.timeline ?? OVERRIDE_THEN_ANA + const rejectMember = () => { const message = "Not members of this workspace: user_zed" return Effect.fail(new AlertValidationError({ message, details: [message] })) @@ -132,65 +149,28 @@ const layer = (seen: Seen) => : Effect.void }, onCallAt: (_org, _id, atMs) => { - seen.onCallAt = atMs - if (seen.longShift === true) { - return Effect.succeed( - Schema.decodeSync(OnCallAtResponse)({ - at: new Date(atMs).toISOString(), - shift: { - userId: "user_ana", - startsAt: new Date(atMs - ONCALL_LOOKAROUND_MS).toISOString(), - endsAt: new Date(atMs + ONCALL_LOOKAROUND_MS).toISOString(), - source: { kind: "layer", layerIndex: 0 }, - }, - }), - ) - } + seen.onCallAt ??= atMs + const shift = timelineOf(seen).find( + (s) => Date.parse(s.startsAt) <= atMs && atMs < Date.parse(s.endsAt), + ) return Effect.succeed( Schema.decodeSync(OnCallAtResponse)({ at: new Date(atMs).toISOString(), - shift: { - userId: "user_ben", - startsAt: "2026-11-12T17:00:00.000Z", - endsAt: "2026-11-13T08:00:00.000Z", - source: { kind: "override", overrideId: OVERRIDE_ID }, - }, + shift: shift ?? null, }), ) }, shifts: (_org, _id, fromMs, toMs) => - seen.longShift === true - ? // Rendered from the clamp edge: the same shift, carrying on. - Effect.succeed( - Schema.decodeSync(OnCallShiftsResponse)({ - from: new Date(fromMs).toISOString(), - to: new Date(toMs).toISOString(), - layers: [], - final: [ - { - userId: "user_ana", - startsAt: new Date(fromMs).toISOString(), - endsAt: new Date(toMs).toISOString(), - source: { kind: "layer", layerIndex: 0 }, - }, - ], - }), - ) - : Effect.succeed( - Schema.decodeSync(OnCallShiftsResponse)({ - from: "2026-11-13T08:00:00.000Z", - to: "2026-11-27T08:00:00.000Z", - layers: [], - final: [ - { - userId: "user_ana", - startsAt: "2026-11-13T08:00:00.000Z", - endsAt: "2026-11-16T08:00:00.000Z", - source: { kind: "layer", layerIndex: 0 }, - }, - ], - }), + Effect.succeed( + Schema.decodeSync(OnCallShiftsResponse)({ + from: new Date(fromMs).toISOString(), + to: new Date(toMs).toISOString(), + layers: [], + final: timelineOf(seen).filter( + (s) => Date.parse(s.endsAt) > fromMs && Date.parse(s.startsAt) < toMs, ), + }), + ), createOverride: (_org, _user, _roles, id, request) => { seen.overrideCreated = request if (seen.rejectMember === true) return rejectMember() @@ -273,11 +253,39 @@ describe("on-call schedule tools", () => { expect(text(result)).toContain("Ana Alves (user_ana) from 2026-11-13T08:00:00.000Z") }) - it("who_is_on_call reports no next shift when the current one runs past the lookaround", async () => { - const result = await ok("who_is_on_call", { schedule: SCHEDULE_ID }, { longShift: true }) - const output = Schema.decodeUnknownSync(WhoIsOnCallOutput)(result.structuredContent) + it("who_is_on_call names the same person's next shift when a one-person rotation hands off", async () => { + const ana = (startsAt: string, endsAt: string) => ({ + userId: "user_ana", + startsAt, + endsAt, + source: { kind: "layer", layerIndex: 0 } as const, + }) + const result = await ok( + "who_is_on_call", + { schedule: SCHEDULE_ID, at: "2026-11-12 19:00:00" }, + { + timeline: [ + ana("2026-11-09T08:00:00.000Z", "2026-11-16T08:00:00.000Z"), + ana("2026-11-16T08:00:00.000Z", "2026-11-23T08:00:00.000Z"), + ], + }, + ) + expect(text(result)).toContain("until 2026-11-16T08:00:00.000Z") + expect(text(result)).toContain( + "Ana Alves (user_ana) from 2026-11-16T08:00:00.000Z until 2026-11-23T08:00:00.000Z", + ) + }) + + it("who_is_on_call finds the first shift after a gap and says when nothing follows", async () => { + const gap = await ok("who_is_on_call", { schedule: SCHEDULE_ID, at: "2026-11-12 10:00:00" }) + expect(text(gap)).toContain("Nobody is on call") + expect(text(gap)).toContain( + "Ben Brook (user_ben) from 2026-11-12T17:00:00.000Z until 2026-11-13T08:00:00.000Z", + ) + const last = await ok("who_is_on_call", { schedule: SCHEDULE_ID, at: "2026-11-14 10:00:00" }) + const output = Schema.decodeUnknownSync(WhoIsOnCallOutput)(last.structuredContent) expect([output.onCall?.user.userId, output.next]).toEqual(["user_ana", null]) - expect(text(result)).toContain("no handoff within two weeks") + expect(text(last)).toContain("no handoff within two weeks") }) it("who_is_on_call takes a REST id and refuses an unknown schedule on the parameter", async () => { diff --git a/apps/ai/src/mcp/tools/who-is-on-call.ts b/apps/ai/src/mcp/tools/who-is-on-call.ts index e71ed567e0..cd91948f26 100644 --- a/apps/ai/src/mcp/tools/who-is-on-call.ts +++ b/apps/ai/src/mcp/tools/who-is-on-call.ts @@ -1,7 +1,6 @@ import type { McpToolRegistrar } from "./types" import { Array as Arr, Clock, Effect, Option, Schema } from "effect" import { parseWarehouseDateTime } from "@maple/query-engine" -import { ONCALL_LOOKAROUND_MS } from "@maple/domain/http" import { WhoIsOnCallOutput } from "@maple/domain/mcp-outputs" import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" import { CurrentMcpTenant } from "../lib/query-warehouse" @@ -40,24 +39,37 @@ export function registerWhoIsOnCallTool(server: McpToolRegistrar) { params.at === undefined ? yield* Clock.currentTimeMillis : parseWarehouseDateTime(params.at) const toError = Effect.mapError(toMcpHttpError("who_is_on_call")) const current = yield* schedules.onCallAt(tenant.orgId, schedule.id, atMs).pipe(toError) + // Next is the shift holding the instant the current one really ends; across a gap, + // the first shift within the horizon, re-read for its real bounds. const nextFrom = current.shift === null ? atMs : isoMs(current.shift.endsAt) - // A shift that ends at the lookaround edge was clamped there, not handed off: the - // "next" one would only be the same shift continuing. - const clamped = nextFrom >= atMs + ONCALL_LOOKAROUND_MS - const upcoming = clamped - ? null - : yield* schedules - .shifts(tenant.orgId, schedule.id, nextFrom, nextFrom + NEXT_SHIFT_HORIZON_MS) - .pipe( - Effect.catchTag("@maple/http/errors/AlertValidationError", () => - Effect.succeed(null), - ), - toError, - ) + const atHandoff = + current.shift === null + ? null + : (yield* schedules.onCallAt(tenant.orgId, schedule.id, nextFrom).pipe(toError)).shift const next = - upcoming === null - ? Option.none() - : Arr.findFirst(upcoming.final, (s) => isoMs(s.startsAt) >= nextFrom) + atHandoff !== null + ? Option.some(atHandoff) + : yield* schedules + .shifts(tenant.orgId, schedule.id, nextFrom, nextFrom + NEXT_SHIFT_HORIZON_MS) + .pipe( + Effect.map((upcoming) => + Arr.findFirst(upcoming.final, (s) => isoMs(s.startsAt) >= nextFrom), + ), + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeedNone, + onSome: (shift) => + schedules + .onCallAt(tenant.orgId, schedule.id, isoMs(shift.startsAt)) + .pipe(Effect.map((at) => Option.fromNullOr(at.shift))), + }), + ), + Effect.catchTag( + "@maple/http/errors/AlertValidationError", + () => Effect.succeedNone, + ), + toError, + ) const names = yield* memberNames(tenant.orgId) yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId, diff --git a/packages/alerting-core/src/oncall.test.ts b/packages/alerting-core/src/oncall.test.ts index 0b244e43e0..811d210b33 100644 --- a/packages/alerting-core/src/oncall.test.ts +++ b/packages/alerting-core/src/oncall.test.ts @@ -64,6 +64,54 @@ describe("weekly rotation", () => { }) }) +describe("one person on consecutive shifts", () => { + // Thursday 2026-03-12 12:34:56.789Z, so a bound built from the instant would show it. + const thursday = at("2026-03-12T12:34:56.789Z") + const solo = (participants: ReadonlyArray) => + decodeConfig({ timeZone: "Europe/Berlin", layers: [{ ...weeklyLayer, participants }] }) + + it("bounds a one-person weekly rotation by its Monday 09:00 handoffs", () => { + const shift = onCallAt(solo(["ana"]), [], thursday) + expect(who(shift)).toBe("ana") + expect(span(shift)).toEqual({ start: "2026-03-09T08:00:00.000Z", end: "2026-03-16T08:00:00.000Z" }) + }) + + it("bounds a rotation repeating the same person by its handoffs too", () => { + expect(span(onCallAt(solo(["ana", "ana"]), [], thursday))).toEqual({ + start: "2026-03-09T08:00:00.000Z", + end: "2026-03-16T08:00:00.000Z", + }) + }) + + it("keeps the calendar merged while the effective schedule splits at each handoff", () => { + const render = renderOnCall(solo(["ana"]), [], WEEKLY_ANCHOR, at("2026-03-23T08:00:00Z")) + expect(Arr.map(render.final, span)).toEqual([ + { start: "2026-03-02T08:00:00.000Z", end: "2026-03-23T08:00:00.000Z" }, + ]) + expect(Arr.map(render.effective, (s) => span(s)?.end)).toEqual([ + "2026-03-09T08:00:00.000Z", + "2026-03-16T08:00:00.000Z", + "2026-03-23T08:00:00.000Z", + ]) + }) + + it("reports an override longer than the lookaround by its own bounds", () => { + const overrides = decodeOverrides([ + { + id: DAN_OVERRIDE, + userId: "dan", + startsAt: at("2026-03-01T00:00:00Z"), + endsAt: at("2026-04-30T00:00:00Z"), + createdAt: 1, + }, + ]) + expect(span(onCallAt(weekly, overrides, thursday))).toEqual({ + start: "2026-03-01T00:00:00.000Z", + end: "2026-04-30T00:00:00.000Z", + }) + }) +}) + describe("daily and custom rotations", () => { it("hands off daily at the local handoff time", () => { const daily = decodeConfig({ @@ -171,8 +219,12 @@ describe("layers and restrictions", () => { it("covers a restriction window that crosses midnight", () => { const night = onCallAt(nights, [], at("2026-01-06T03:00:00Z")) expect(who(night)).toBe("ana") - // Pieces of consecutive daily shifts for the same user merge into one window. - expect(span(night)).toEqual({ start: "2026-01-05T22:00:00.000Z", end: "2026-01-06T06:00:00.000Z" }) + // The midnight rotation handoff bounds the shift even though Ana carries on. + expect(span(night)).toEqual({ start: "2026-01-06T00:00:00.000Z", end: "2026-01-06T06:00:00.000Z" }) + expect(span(onCallAt(nights, [], at("2026-01-05T23:00:00Z")))).toEqual({ + start: "2026-01-05T22:00:00.000Z", + end: "2026-01-06T00:00:00.000Z", + }) }) it("reports a gap where no layer is active", () => { @@ -299,7 +351,7 @@ describe("render and onCallAt agree", () => { ) const mismatches = Arr.filter(samples, (t) => { - const expected = Arr.findFirst(render.final, (s) => s.startMs <= t && t < s.endMs).pipe( + const expected = Arr.findFirst(render.effective, (s) => s.startMs <= t && t < s.endMs).pipe( Option.getOrNull, ) const actual = onCallAt(config, overrides, t) diff --git a/packages/alerting-core/src/oncall.ts b/packages/alerting-core/src/oncall.ts index b6fa7d5e0d..e716fc3003 100644 --- a/packages/alerting-core/src/oncall.ts +++ b/packages/alerting-core/src/oncall.ts @@ -35,7 +35,9 @@ export interface OnCallShift { export interface OnCallRender { /** Each layer's own shifts after its restrictions, clipped to the range. */ readonly layers: ReadonlyArray> - /** The effective schedule after layering and overrides. */ + /** The effective schedule after layering and overrides, split at every handoff. */ + readonly effective: ReadonlyArray + /** `effective` with back-to-back shifts of the same person and source merged, for display. */ readonly final: ReadonlyArray } @@ -44,6 +46,11 @@ interface Interval { readonly endMs: number } +/** A shift clipped to the render range, with the unclipped bounds of the shift it came from. */ +interface Piece extends OnCallShift { + readonly full: Interval +} + const DAY_MS = 86_400_000 const HOUR_MS = 3_600_000 @@ -169,7 +176,7 @@ const layerShifts = ( layerIndex: number, fromMs: number, toMs: number, -): Array => { +): Array => { const startMs = Math.max(fromMs, layer.startsAt) if (startMs >= toMs) return [] const clock = rotationClock(zone, layer.rotation, layer.startsAt) @@ -178,16 +185,20 @@ const layerShifts = ( const source: OnCallSource = { kind: "layer", layerIndex } const rotationShifts = Arr.flatMap( Arr.makeBy(Math.max(count, 1), (i) => first + i), - (index) => - clip(clock.bounds(index), startMs, toMs).pipe( + (index) => { + const bounds = clock.bounds(index) + const full = { startMs: Math.max(bounds.startMs, layer.startsAt), endMs: bounds.endMs } + return clip(full, startMs, toMs).pipe( Option.map((interval) => ({ ...interval, + full, userId: layer.participants[mod(index, layer.participants.length)] ?? layer.participants[0], source, })), Option.toArray, - ), + ) + }, ) const restrictions = layer.restrictions ?? [] if (!Arr.isReadonlyArrayNonEmpty(restrictions)) return rotationShifts @@ -195,7 +206,11 @@ const layerShifts = ( return Arr.flatMap(rotationShifts, (shift) => Arr.flatMap(windows, (window) => clip(window, shift.startMs, shift.endMs).pipe( - Option.map((piece) => ({ ...shift, ...piece })), + Option.map((piece) => ({ + ...shift, + ...piece, + full: Option.getOrElse(clip(window, shift.full.startMs, shift.full.endMs), () => piece), + })), Option.toArray, ), ), @@ -203,12 +218,12 @@ const layerShifts = ( } /** Binary search over sorted, disjoint shifts. */ -const shiftCovering = ( - shifts: ReadonlyArray, +const shiftCovering = ( + shifts: ReadonlyArray, ms: number, lo = 0, hi = shifts.length - 1, -): Option.Option => { +): Option.Option => { if (lo > hi) return Option.none() const mid = Math.floor((lo + hi) / 2) const shift = shifts[mid] @@ -229,6 +244,17 @@ const sameSource = (a: OnCallSource, b: OnCallSource): boolean => ? b.kind === "override" && a.overrideId === b.overrideId : b.kind === "layer" && a.layerIndex === b.layerIndex +// The same underlying shift: one override, or one rotation shift's piece of a layer. +const samePiece = (a: Piece, b: Piece): boolean => + sameSource(a.source, b.source) && a.full.startMs === b.full.startMs && a.full.endMs === b.full.endMs + +const toShift = ({ userId, startMs, endMs, source }: Piece): OnCallShift => ({ + userId, + startMs, + endMs, + source, +}) + const resolveZone = (config: OnCallScheduleConfig): DateTime.TimeZone => // A decoded config always names a valid zone; UTC only guards undecoded input. Option.getOrElse(DateTime.zoneMakeNamed(config.timeZone), () => DateTime.zoneMakeOffset(0)) @@ -243,8 +269,33 @@ export const renderOnCall = ( fromMs: number, toMs: number, ): OnCallRender => { + const { layers, effective } = renderPieces(config, overrides, fromMs, toMs) + const shifts = Arr.map(effective, toShift) + const final = Arr.reduce(shifts, Arr.empty(), (acc, shift) => { + const last = acc[acc.length - 1] + if ( + last !== undefined && + last.endMs === shift.startMs && + last.userId === shift.userId && + sameSource(last.source, shift.source) + ) { + acc[acc.length - 1] = { ...last, endMs: shift.endMs } + } else { + acc.push(shift) + } + return acc + }) + return { layers: Arr.map(layers, (layer) => Arr.map(layer, toShift)), effective: shifts, final } +} + +const renderPieces = ( + config: OnCallScheduleConfig, + overrides: ReadonlyArray, + fromMs: number, + toMs: number, +): { readonly layers: ReadonlyArray>; readonly effective: Array } => { const endMs = Math.min(toMs, fromMs + MAX_ONCALL_RENDER_RANGE_MS) - if (endMs <= fromMs) return { layers: Arr.map(config.layers, () => []), final: [] } + if (endMs <= fromMs) return { layers: Arr.map(config.layers, () => []), effective: [] } const zone = resolveZone(config) const layers = Arr.map(config.layers, (layer, index) => layerShifts(zone, layer, index, fromMs, endMs)) const activeOverrides = Arr.filter( @@ -275,26 +326,28 @@ export const renderOnCall = ( }), ) - const winnerAt = (ms: number): Option.Option> => { + const winnerAt = (ms: number): Option.Option => { const covering = Arr.filter(activeOverrides, (o) => o.startsAt <= ms && ms < o.endsAt) if (Arr.isArrayNonEmpty(covering)) { const winner = Arr.max(covering, overrideOrder) - return Option.some({ userId: winner.userId, source: { kind: "override", overrideId: winner.id } }) + const full = { startMs: winner.startsAt, endMs: winner.endsAt } + return Option.some({ + ...full, + full, + userId: winner.userId, + source: { kind: "override", overrideId: winner.id }, + }) } return Arr.findFirst(Arr.reverse(layers), (shifts) => shiftCovering(shifts, ms)) } - const final = Arr.reduce(segments, Arr.empty(), (acc, segment) => + // Segments merge only within one underlying shift, so every rotation handoff stays a bound. + const effective = Arr.reduce(segments, Arr.empty(), (acc, segment) => Option.match(winnerAt(segment.startMs), { onNone: () => acc, onSome: (winner) => { const last = acc[acc.length - 1] - if ( - last !== undefined && - last.endMs === segment.startMs && - last.userId === winner.userId && - sameSource(last.source, winner.source) - ) { + if (last !== undefined && last.endMs === segment.startMs && samePiece(last, winner)) { acc[acc.length - 1] = { ...last, endMs: segment.endMs } } else { acc.push({ ...winner, ...segment }) @@ -303,21 +356,30 @@ export const renderOnCall = ( }, }), ) - return { layers, final } + return { layers, effective } } /** - * Who is on call at `atMs`, with the bounds of the effective shift containing it (clamped - * to ONCALL_LOOKAROUND_MS either side). Null is a gap: nobody is on call. + * Who is on call at `atMs`, with the bounds of the effective shift containing it: the real + * handoffs around it. Null is a gap: nobody is on call. Resolution looks ONCALL_LOOKAROUND_MS + * either side; a bound past that is the winning shift's own edge. */ export const onCallAt = ( config: OnCallScheduleConfig, overrides: ReadonlyArray, atMs: number, -): OnCallShift | null => - Option.getOrNull( - shiftCovering( - renderOnCall(config, overrides, atMs - ONCALL_LOOKAROUND_MS, atMs + ONCALL_LOOKAROUND_MS).final, - atMs, +): OnCallShift | null => { + const fromMs = atMs - ONCALL_LOOKAROUND_MS + const toMs = atMs + ONCALL_LOOKAROUND_MS + return Option.getOrNull( + shiftCovering(renderPieces(config, overrides, fromMs, toMs).effective, atMs).pipe( + Option.map((piece) => + toShift({ + ...piece, + startMs: piece.startMs === fromMs ? piece.full.startMs : piece.startMs, + endMs: piece.endMs === toMs ? piece.full.endMs : piece.endMs, + }), + ), ), ) +} From b5b72370678188b6fcb635d9618683cade263045 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 15:27:33 +0100 Subject: [PATCH 64/98] fix(api): on-call 400s name the wire field and what is wrong with it A layer or override instant before 1970 is refused on the wire with its param instead of a camelCase internal path, empty participants, layers and weekdays say so, and a v2 timestamp that does not parse says it is not a valid ISO-8601 UTC timestamp. --- apps/api/src/routes/v2/alerts.http.test.ts | 58 +++++++++++++++++++ .../domain/src/http/v2/oncall-schedules.ts | 19 ++++-- packages/domain/src/http/v2/timestamp.ts | 1 + 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index ec4fd4d879..ebdd6251e5 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -1670,6 +1670,64 @@ describe("v2 alerts over HTTP", () => { await harness.dispose() }) + it("names the wire field and what is wrong with it when an on-call request is refused", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(["alerts:write"]) + const created = await harness.request( + "POST", + "/v2/alerts/oncall_schedules", + key.secret, + weeklySchedule, + ) + const base = `/v2/alerts/oncall_schedules/${created.body.id}` + const [layer] = weeklySchedule.layers + const create = (body: unknown) => + harness.request("POST", "/v2/alerts/oncall_schedules", key.secret, body) + const responses = await Promise.all([ + create({ ...weeklySchedule, layers: [{ ...layer, starts_at: "1960-01-01T00:00:00.000Z" }] }), + create({ ...weeklySchedule, layers: [{ ...layer, participants: [] }] }), + create({ ...weeklySchedule, layers: [] }), + create({ ...weeklySchedule, layers: [{ ...layer, starts_at: "next monday" }] }), + create({ + ...weeklySchedule, + layers: [ + { ...layer, restrictions: [{ weekdays: [], start_time: "18:00", end_time: "08:00" }] }, + ], + }), + harness.request("POST", `${base}/overrides`, key.secret, { + user_id: "user_ben", + starts_at: "1969-12-31T23:59:59.000Z", + ends_at: "2030-01-04T08:00:00.000Z", + }), + harness.request("GET", `${base}/oncall?at=yesterday`, key.secret), + ]) + expect( + Arr.map(responses, (response) => [ + response.status, + response.body.error.param, + response.body.error.message, + ]), + ).toEqual([ + [400, "layers[0].starts_at", "layers[0].starts_at: must be on or after 1970-01-01T00:00:00Z"], + [400, "layers[0].participants", "layers[0].participants: needs at least one member"], + [400, "layers", "layers: needs at least one layer"], + [ + 400, + "layers[0].starts_at", + "layers[0].starts_at: is not a valid ISO-8601 UTC timestamp, e.g. 2026-07-15T12:34:56.000Z", + ], + [ + 400, + "layers[0].restrictions[0].weekdays", + "layers[0].restrictions[0].weekdays: needs at least one weekday", + ], + [400, "starts_at", "starts_at: must be on or after 1970-01-01T00:00:00Z"], + [400, "at", "at: is not a valid ISO-8601 UTC timestamp, e.g. 2026-07-15T12:34:56.000Z"], + ]) + + await harness.dispose() + }) + it("rejects bad on-call times and layers with 400s, unknown schedules with 404 and read-only writes with 403", async () => { const harness = makeHarness() const key = await harness.bootstrapKey(["alerts:write"]) diff --git a/packages/domain/src/http/v2/oncall-schedules.ts b/packages/domain/src/http/v2/oncall-schedules.ts index e5c991f6ff..fc183e4031 100644 --- a/packages/domain/src/http/v2/oncall-schedules.ts +++ b/packages/domain/src/http/v2/oncall-schedules.ts @@ -69,9 +69,16 @@ export const V2OnCallRotation = Schema.Union([ }) export type V2OnCallRotation = Schema.Schema.Type +// Stored as epoch ms, so an instant before 1970 is refused on the wire with its field. +const OnCallInstant = Timestamp.check( + Schema.makeFilter((value: string) => Date.parse(value) >= 0, { + message: "must be on or after 1970-01-01T00:00:00Z", + }), +) + export const V2OnCallRestriction = Schema.Struct({ weekdays: Schema.Array(OnCallWeekday) - .pipe(Schema.refine(Arr.isReadonlyArrayNonEmpty)) + .pipe(Schema.refine(Arr.isReadonlyArrayNonEmpty, { message: "needs at least one weekday" })) .check(Schema.isMinLength(1), Schema.isMaxLength(7)) .annotate({ description: "Days the window starts on." }), start_time: OnCallLocalTime.annotate({ description: "Local start time, `HH:MM`.", examples: ["18:00"] }), @@ -92,13 +99,13 @@ export const V2OnCallLayer = Schema.Struct({ examples: ["Primary"], }), participants: Schema.Array(ScheduleUserId) - .pipe(Schema.refine(Arr.isReadonlyArrayNonEmpty)) + .pipe(Schema.refine(Arr.isReadonlyArrayNonEmpty, { message: "needs at least one member" })) .check(Schema.isMinLength(1), Schema.isMaxLength(MAX_ONCALL_PARTICIPANTS)) .annotate({ description: `Who rotates through the layer, in order: 1 to ${MAX_ONCALL_PARTICIPANTS} members.`, }), rotation: V2OnCallRotation, - starts_at: Timestamp.annotate({ + starts_at: OnCallInstant.annotate({ description: "Rotation anchor: the first participant holds the shift that contains this instant.", }), restrictions: Schema.optionalKey( @@ -116,7 +123,7 @@ export const V2OnCallLayer = Schema.Struct({ export type V2OnCallLayer = Schema.Schema.Type export const V2OnCallLayers = Schema.Array(V2OnCallLayer) - .pipe(Schema.refine(Arr.isReadonlyArrayNonEmpty)) + .pipe(Schema.refine(Arr.isReadonlyArrayNonEmpty, { message: "needs at least one layer" })) .check(Schema.isMinLength(1), Schema.isMaxLength(MAX_ONCALL_LAYERS)) .annotate({ description: `The layers, bottom first: 1 to ${MAX_ONCALL_LAYERS}.` }) @@ -414,8 +421,8 @@ export type V2OnCallOverride = Schema.Schema.Type export const V2OnCallOverrideCreateParams = Schema.Struct({ user_id: ScheduleUserId, - starts_at: Timestamp, - ends_at: Timestamp.annotate({ + starts_at: OnCallInstant, + ends_at: OnCallInstant.annotate({ description: "After `starts_at`, in the future, and at most 90 days after `starts_at`.", }), }).annotate({ diff --git a/packages/domain/src/http/v2/timestamp.ts b/packages/domain/src/http/v2/timestamp.ts index 20eb1a8ca1..79fa12ff06 100644 --- a/packages/domain/src/http/v2/timestamp.ts +++ b/packages/domain/src/http/v2/timestamp.ts @@ -7,6 +7,7 @@ export const Timestamp = Schema.String.pipe( Schema.check( Schema.makeFilter((value) => ISO_8601_UTC_PATTERN.test(value) && Number.isFinite(Date.parse(value)), { description: "Expected an ISO-8601 UTC timestamp", + message: "is not a valid ISO-8601 UTC timestamp, e.g. 2026-07-15T12:34:56.000Z", }), ), Schema.annotate({ From e1ebeadb4235d3f1e10c3701a65bf878a0e8e003 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 15:29:45 +0100 Subject: [PATCH 65/98] fix(alerts): incident timelines say why a page failed and read a queued error as a retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_incident_timeline carries each page's error message and renders it (failed: …, retrying: … for a queued page with an error); the web timeline's detail for a pending page with an error reads Retrying: …. Addresses are never part of the message. --- apps/ai/src/mcp/tool-descriptors.generated.ts | 2 +- .../mcp/tools/__tests__/alert-tools.test.ts | 20 ++++++--- .../ai/src/mcp/tools/get-incident-timeline.ts | 41 +++++++++++-------- .../domain/src/http/v2/alert-incidents.ts | 3 +- packages/domain/src/mcp-outputs/alerts.ts | 2 + .../src/alerts/incident-timeline.test.ts | 26 ++++++++++++ .../web-data/src/alerts/incident-timeline.ts | 5 ++- 7 files changed, 73 insertions(+), 26 deletions(-) diff --git a/apps/ai/src/mcp/tool-descriptors.generated.ts b/apps/ai/src/mcp/tool-descriptors.generated.ts index 38c01b1609..b3413d85e6 100644 --- a/apps/ai/src/mcp/tool-descriptors.generated.ts +++ b/apps/ai/src/mcp/tool-descriptors.generated.ts @@ -33,7 +33,7 @@ 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"},"acknowledged":{"type":"boolean","description":"true: open incidents someone acknowledged; false: open incidents nobody has. Implies status=open"},"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"}]},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"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"},"acknowledged":{"type":"boolean"},"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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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)."},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of destination_ids, paging step by step until acknowledged (from list_escalation_chains): its UUID or its REST id (esc_\u2026)"},"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, and who escalation paged: 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"}]},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"anyOf":[{"type":"string"},{"type":"null"}]},"pages":{"type":"array","items":{"type":"object","properties":{"userId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"channel":{"type":"string"},"status":{"type":"string"},"at":{"type":"string"}},"required":["userId","name","channel","status","at"],"additionalProperties":true}}},"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"},"acknowledged":{"type":"boolean"},"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, and who escalation paged: 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"}]},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"anyOf":[{"type":"string"},{"type":"null"}]},"pages":{"type":"array","items":{"type":"object","properties":{"userId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"channel":{"type":"string"},"status":{"type":"string"},"errorMessage":{"type":"string"},"at":{"type":"string"}},"required":["userId","name","channel","status","at"],"additionalProperties":true}}},"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"},"acknowledged":{"type":"boolean"},"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). escalation_chain_id adds step-by-step paging on top.","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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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."},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of destination_ids, paging step by step until acknowledged (from list_escalation_chains): its UUID or its REST id (esc_\u2026)"}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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}}"},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of the destinations (from list_escalation_chains), or \"none\" to detach. Applies to escalation_chain_tier; without it, an id sets the critical (or only) tier and \"none\" detaches every tier: its UUID or its REST id (esc_\u2026)"},"escalation_chain_tier":{"type":"string","enum":["warning","critical"],"description":"The tier escalation_chain_id sets or detaches, leaving the other tier's chain as it is"}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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}}, 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 9635889100..40aa59f30a 100644 --- a/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts @@ -296,10 +296,16 @@ const layer = (seen: Seen) => incidentId === INCIDENT_ID ? Arr.map( [ - ["email", "sent"], - ["slack", "failed"], + ["email", "sent", null], + ["slack", "failed", "The chat platform refused the direct message"], + ["email", "queued", "The email could not be sent"], + [ + "email", + "skipped", + "Stopped retrying: the incident was acknowledged", + ], ] as const, - ([channel, status], index) => ({ + ([channel, status, errorMessage], index) => ({ id: `page-${index}`, incidentId, createdAt: Date.parse(NOW), @@ -308,8 +314,7 @@ const layer = (seen: Seen) => name: "Ben Brook", channel, status, - errorMessage: - status === "failed" ? "messages_tab_disabled" : null, + errorMessage, }, }), ) @@ -537,7 +542,10 @@ describe("alert tools", () => { const result = await ok("get_incident_timeline", { rule_id: RULE_ID, limit: 1 }, seen) expect(seen.incidentOptions).toMatchObject({ ruleId: RULE_ID, limit: 2 }) expect(text(result)).toContain(`Incident ID: ${INCIDENT_ID}`) - expect(text(result)).toContain("Paged: Ben Brook by email, Ben Brook by Slack DM (failed)") + expect(text(result)).toContain( + "Paged: Ben Brook by email, Ben Brook by Slack DM (failed: The chat platform refused the direct message), Ben Brook by email (retrying: The email could not be sent), Ben Brook by email (Stopped retrying: the incident was acknowledged)", + ) + expect(text(result)).not.toContain("@") expect(text(result)).toContain("Showing 1 incidents.") const bad = await run("get_incident_timeline", { rule_id: "not-a-uuid" }) diff --git a/apps/ai/src/mcp/tools/get-incident-timeline.ts b/apps/ai/src/mcp/tools/get-incident-timeline.ts index 274341b97d..216c27ad81 100644 --- a/apps/ai/src/mcp/tools/get-incident-timeline.ts +++ b/apps/ai/src/mcp/tools/get-incident-timeline.ts @@ -27,27 +27,31 @@ function formatTimestamp(iso: string | null): string | undefined { const channelLabel = (channel: string): string => channel === "email" ? "email" : `${Str.capitalize(channel)} DM` -const pageStatus = (status: string): string => - status === "sent" - ? "" - : status === "skipped" - ? " (not sent: incident acknowledged or resolved)" - : ` (${status})` +interface PageRow { + readonly userId: string + readonly name: string | null + readonly channel: string + readonly status: string + readonly errorMessage?: string +} + +// A queued page with an error is waiting on a retry, not stuck. +const pageStatus = ({ status, errorMessage }: PageRow): string => { + if (status === "sent") return "" + if (errorMessage === undefined) { + return status === "skipped" ? " (not sent: incident acknowledged or resolved)" : ` (${status})` + } + if (status === "skipped") return ` (${errorMessage})` + if (status === "queued" || status === "sending") return ` (retrying: ${errorMessage})` + return ` (${status}: ${errorMessage})` +} -/** e.g. `Ben by email, Ben by Slack DM (failed)`; never the address. */ -const formatPages = ( - pages: ReadonlyArray<{ - readonly userId: string - readonly name: string | null - readonly channel: string - readonly status: string - }>, -): string => +/** e.g. `Ben by email, Ben by Slack DM (failed: …)`; never the address. */ +const formatPages = (pages: ReadonlyArray): string => Arr.join( Arr.map( pages, - (page) => - `${page.name ?? page.userId} by ${channelLabel(page.channel)}${pageStatus(page.status)}`, + (page) => `${page.name ?? page.userId} by ${channelLabel(page.channel)}${pageStatus(page)}`, ), ", ", ) @@ -147,6 +151,9 @@ export function registerGetIncidentTimelineTool(server: McpToolRegistrar) { name: entry.page.name, channel: entry.page.channel, status: entry.page.status, + ...(entry.page.errorMessage === null + ? undefined + : { errorMessage: entry.page.errorMessage }), at: DateTime.formatIso(DateTime.makeUnsafe(entry.createdAt)), })), }), diff --git a/packages/domain/src/http/v2/alert-incidents.ts b/packages/domain/src/http/v2/alert-incidents.ts index a01490a933..f05c3c0d98 100644 --- a/packages/domain/src/http/v2/alert-incidents.ts +++ b/packages/domain/src/http/v2/alert-incidents.ts @@ -192,7 +192,8 @@ export const V2AlertIncidentEvent = Schema.Struct({ "`sent`; `queued` or `sending` while on its way; `failed` when the channel refused it; `skipped` when the incident was acknowledged or resolved first.", }), error_message: Schema.NullOr(Schema.String).annotate({ - description: "Why a `failed` or `skipped` page was not delivered.", + description: + "Why a `failed` or `skipped` page was not delivered; on a `queued` page, why the last attempt failed before the retry.", }), }).annotate({ description: "Set on a `paged` event. Never carries the address." }), ), diff --git a/packages/domain/src/mcp-outputs/alerts.ts b/packages/domain/src/mcp-outputs/alerts.ts index db900e5455..e228b89c79 100644 --- a/packages/domain/src/mcp-outputs/alerts.ts +++ b/packages/domain/src/mcp-outputs/alerts.ts @@ -323,6 +323,8 @@ export const IncidentTimelineRow = Schema.Struct({ /** `email`, or the chat connector of the direct message. */ channel: Schema.String, status: Schema.String, + /** Why a page failed or was skipped, or why a queued one is being retried. */ + errorMessage: Schema.optionalKey(Schema.String), at: Schema.String, }), ), diff --git a/packages/web-data/src/alerts/incident-timeline.test.ts b/packages/web-data/src/alerts/incident-timeline.test.ts index c85634e7cb..6bf423107a 100644 --- a/packages/web-data/src/alerts/incident-timeline.test.ts +++ b/packages/web-data/src/alerts/incident-timeline.test.ts @@ -265,6 +265,32 @@ describe("buildIncidentTimeline", () => { expect(entries[2]?.detail).toBe("The chat account could not be reached") }) + it("reads a queued page with an error as a retry", () => { + const entries = build( + makeIncident(), + [], + [ + makeActivity({ + type: "paged", + actor: null, + source: null, + created_at: "2026-10-15T14:21:00.000Z", + page: { + user_id: "user_ben", + name: "Ben Brook", + channel: "email", + status: "queued", + error_message: "The email could not be sent", + }, + }), + ], + ) + expect([entries[1]?.title, entries[1]?.detail]).toEqual([ + "Paging Ben Brook by email", + "Retrying: The email could not be sent", + ]) + }) + it("keeps the state still in effect last when an ack came after it", () => { const entries = build( makeIncident({ holdReason: "no_data", heldSince: "2026-10-15T14:40:00.000Z" }), diff --git a/packages/web-data/src/alerts/incident-timeline.ts b/packages/web-data/src/alerts/incident-timeline.ts index 9c09d723c8..cae54aa581 100644 --- a/packages/web-data/src/alerts/incident-timeline.ts +++ b/packages/web-data/src/alerts/incident-timeline.ts @@ -107,7 +107,10 @@ function activityEntry(event: V2AlertIncidentEvent): IncidentTimelineEntry { : page?.status === "queued" || page?.status === "sending" ? { tone: "info" as const, title: `Paging ${who}${channel}`, pending: true } : { tone: "info" as const, title: `Paged ${who}${channel}`, pending: false } - return { ...base, ...outcome, detail: page?.error_message ?? null } + const error = page?.error_message ?? null + // A pending page with an error is between retries. + const detail = outcome.pending && error !== null ? `Retrying: ${error}` : error + return { ...base, ...outcome, detail } } } } From aa108195e083eb13ce8170014d6e87c59942a014 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 15:29:45 +0100 Subject: [PATCH 66/98] fix(alerts): a page stopped by an ack after failed attempts says it stopped retrying A page with earlier attempts that meets an acknowledged or resolved incident reads "Stopped retrying: the incident was acknowledged" rather than claiming it never went out. --- .../services/alerts/AlertMemberNotifier.ts | 10 ++++- .../AlertsService.oncall-paging.test.ts | 41 ++++++++++++++++++- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/services/alerts/AlertMemberNotifier.ts b/packages/backend/src/services/alerts/AlertMemberNotifier.ts index d430f6cf6d..8f8c84b3a9 100644 --- a/packages/backend/src/services/alerts/AlertMemberNotifier.ts +++ b/packages/backend/src/services/alerts/AlertMemberNotifier.ts @@ -376,10 +376,16 @@ export class AlertMemberNotifier extends Context.Service 0 yield* settle(row, claimedAt, { status: "skipped", - errorMessage: - Option.isSome(incident) && incident.value.status === "open" + errorMessage: tried + ? acknowledged + ? "Stopped retrying: the incident was acknowledged" + : "Stopped retrying: the incident resolved" + : acknowledged ? "Not sent: the incident was acknowledged before this page went out" : "Not sent: the incident resolved before this page went out", }) diff --git a/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts b/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts index 448b7f0875..96e754477a 100644 --- a/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts +++ b/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts @@ -555,7 +555,7 @@ describe("escalation pages the on-call", () => { testDb, `insert into alert_member_notifications (id, org_id, incident_id, run_id, user_id, channel, status, attempt_number, scheduled_at, payload_json, dedupe_key, created_at, updated_at) - select gen_random_uuid()::text, org_id, incident_id, run_id, $2, channel, 'queued', 1, + select gen_random_uuid()::text, org_id, incident_id, run_id, $2, channel, 'queued', 0, scheduled_at, payload_json, dedupe_key || ':late', created_at, updated_at from alert_member_notifications where org_id = $1`, [orgId, chen], @@ -575,7 +575,13 @@ describe("escalation pages the on-call", () => { Arr.map(late, (row) => row.status), ["skipped", "skipped"], ) - assert.include(late[0]?.error_message ?? "", "acknowledged") + assert.deepStrictEqual( + Arr.map(late, (row) => row.error_message), + [ + "Not sent: the incident was acknowledged before this page went out", + "Not sent: the incident was acknowledged before this page went out", + ], + ) assert.notInclude( Arr.map(emails, (email) => email.to), "chen@acme.test", @@ -638,6 +644,37 @@ describe("escalation pages the on-call", () => { }) }) + it.effect("stops retrying a failed DM once the incident is acknowledged, and says so", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRate(10) } + const emails: Array<{ to: string; subject: string; html: string }> = [] + return Effect.gen(function* () { + const chat = yield* makeChat + yield* Ref.set(chat.mode, "transient") + yield* TestClock.setTime(NOW) + return yield* Effect.gen(function* () { + const { alerts, orgId } = yield* setup("org_page_retry_ack", testDb) + yield* ticks(alerts, 10) + yield* Effect.promise(() => + executeSql( + testDb, + `update alert_incidents set acknowledged_at = now(), acknowledged_by_name = 'Ana', + acknowledged_via = 'web' where org_id = $1`, + [orgId], + ), + ) + yield* Ref.set(chat.mode, "ok") + yield* ticks(alerts, 2) + const slack = Arr.filter(yield* pageRows(testDb, orgId), (row) => row.channel === "slack") + assert.deepStrictEqual( + Arr.map(slack, (row) => [row.status, row.error_message]), + [["skipped", "Stopped retrying: the incident was acknowledged"]], + ) + assert.deepStrictEqual(chat.attempts, ["transient"]) + }).pipe(Effect.provide(layerFor(testDb, state, emails, chat))) + }) + }) + it.effect("tries each of the org's Slack workspaces until one knows the person", () => { const testDb = createTestDb(trackedDbs) const state = { tracesAggregateRows: errorRate(10) } From 2ce204b9ec23547759aca8887c8c4759f4639679 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 15:31:52 +0100 Subject: [PATCH 67/98] test(mcp): import the shift document type the on-call tool stub uses --- apps/ai/src/mcp/tools/__tests__/oncall-schedule-tools.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ai/src/mcp/tools/__tests__/oncall-schedule-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/oncall-schedule-tools.test.ts index f71d5b67ee..7123b8cf69 100644 --- a/apps/ai/src/mcp/tools/__tests__/oncall-schedule-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/oncall-schedule-tools.test.ts @@ -16,6 +16,7 @@ import { OnCallScheduleNotFoundError, EscalationChainId, OnCallSchedulesListResponse, + OnCallShiftDocument, OnCallShiftsResponse, OrgId, UserId, From 02876c9e92e6692fb4eb723bb9ed0b3251b96e6a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 15:32:59 +0100 Subject: [PATCH 68/98] fix(alerts): an email page fails at once where the deployment cannot send email --- .../services/alerts/AlertMemberNotifier.ts | 3 +++ .../AlertsService.oncall-paging.test.ts | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/packages/backend/src/services/alerts/AlertMemberNotifier.ts b/packages/backend/src/services/alerts/AlertMemberNotifier.ts index 8f8c84b3a9..86bda87621 100644 --- a/packages/backend/src/services/alerts/AlertMemberNotifier.ts +++ b/packages/backend/src/services/alerts/AlertMemberNotifier.ts @@ -51,6 +51,7 @@ const MAX_ATTEMPTS = DEFAULT_ALERT_DELIVERY_RETRY_POLICY.maxAttempts */ const FailureMessages = { directoryNotConfigured: "Member emails cannot be looked up on this deployment", + emailNotConfigured: "Email is not set up on this deployment", directoryUnavailable: "The member directory could not be read", noEmail: "No email address is known for this member", emailFailed: "The email could not be sent", @@ -279,6 +280,8 @@ export class AlertMemberNotifier extends Context.Service({ status: "sent" }), diff --git a/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts b/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts index 96e754477a..7cd23cefbf 100644 --- a/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts +++ b/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts @@ -616,6 +616,29 @@ describe("escalation pages the on-call", () => { }) }) + it.effect("fails an email page at once on a deployment that cannot send email", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRate(10) } + const emails: Array<{ to: string; subject: string; html: string }> = [] + const unconfigured: (typeof EmailService)["Service"] = { + isConfigured: false, + send: () => Effect.die("an unconfigured sender is never asked"), + } + return Effect.gen(function* () { + const chat = yield* makeChat + yield* TestClock.setTime(NOW) + return yield* Effect.gen(function* () { + const { alerts, orgId } = yield* setup("org_page_no_email", testDb) + yield* ticks(alerts, 20) + const email = Arr.filter(yield* pageRows(testDb, orgId), (row) => row.channel === "email") + assert.deepStrictEqual( + Arr.map(email, (row) => [row.status, row.attempt_number, row.error_message]), + [["failed", 1, "Email is not set up on this deployment"]], + ) + }).pipe(Effect.provide(layerFor(testDb, state, emails, chat, { email: unconfigured }))) + }) + }) + it.effect("retries a DM that failed for a passing reason, and sends it", () => { const testDb = createTestDb(trackedDbs) const state = { tracesAggregateRows: errorRate(10) } From b83393792b4ebc1ad91afd85bdb7b1601e1608f6 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 15:47:41 +0100 Subject: [PATCH 69/98] fix(views): override bars and their legend swatch read clearly in dark mode --- packages/views/src/ui/shift-timeline.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/views/src/ui/shift-timeline.ts b/packages/views/src/ui/shift-timeline.ts index 1def535189..f46f006200 100644 --- a/packages/views/src/ui/shift-timeline.ts +++ b/packages/views/src/ui/shift-timeline.ts @@ -101,7 +101,9 @@ export const timelineDays = (fromMs: number, toMs: number, timeZone: string): Re const BAR_TONE = { neutral: "bg-surface-strong border-border text-foreground", primary: toneBoxClass("primary"), - info: toneBoxClass("info"), + // An override is the one bar that must stand out, even 8px wide on a dark surface: a stronger + // wash and the tone's own edge. + info: "bg-severity-info-a3 border-severity-info text-severity-info", } as const satisfies Record /** The narrowest a bar draws, so a short shift stays visible and clickable. */ From 690f0871c355a81e31e890527e9e1697438248cc Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 15:47:41 +0100 Subject: [PATCH 70/98] fix(alerts): an ended shift cannot open an override; a running one is covered from now to its end --- .../alerts/schedule/schedule.scene.test.ts | 51 ++++++++++++++++++- apps/web/src/page/alerts/schedule/update.ts | 3 +- apps/web/src/page/alerts/schedule/view.ts | 6 ++- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts index cb6100b792..3665ccb3a1 100644 --- a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts +++ b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts @@ -4,7 +4,9 @@ import { OnCallOverrideId } from "@maple/domain/http" import { zonedDayStart } from "@maple/views/ui/shift-timeline" import { Option, Result, Schema, Array as Arr } from "effect" import { AsyncData } from "foldkit" -import { isoMs } from "../shared/time" +import { isoMs, msIso } from "../shared/time" +import { memberName } from "../shared/org-members" +import { formatShiftEdge } from "../shared/oncall-schedules" import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" @@ -316,4 +318,51 @@ describe("on-call schedule page", () => { expectView(text("Times in Europe/Berlin. Yours is UTC.")).toExist(), ) }) + + test("an ended shift is a read-only bar; a running one opens an override from now to its real end", () => { + const model = loaded(ONCALL_NOW) + const shifts = Option.getOrThrow(AsyncData.getData(model.shifts.data)) + const weekStart = Option.getOrElse( + Option.map(rangeOf(model), (range) => range.fromMs), + () => 0, + ) + const ended = Option.getOrThrow( + Arr.findFirst( + shifts.final, + (shift) => isoMs(shift.starts_at) >= weekStart && isoMs(shift.ends_at) <= ONCALL_NOW, + ), + ) + const running = Option.getOrThrow( + Arr.findFirst( + shifts.final, + (shift) => isoMs(shift.starts_at) <= ONCALL_NOW && ONCALL_NOW < isoMs(shift.ends_at), + ), + ) + const endedName = `${memberName(oncallMembers, ended.user_id)}, ${formatShiftEdge(isoMs(ended.starts_at), BERLIN)} to ${formatShiftEdge(isoMs(ended.ends_at), BERLIN)}` + scene( + { update: pageUpdate, view }, + given(model), + expectView(role("img", { name: endedName })).toExist(), + expectView(role("button", { name: endedName })).not.toExist(), + ) + const ignored = pageUpdate( + model, + Message.ClickedShift({ startMs: isoMs(ended.starts_at), endMs: isoMs(ended.ends_at) }), + ) + expect(ignored.model.overrideOpenCount).toBe(model.overrideOpenCount) + const opened = run(model, [ + Message.ClickedShift({ startMs: isoMs(running.starts_at), endMs: isoMs(running.ends_at) }), + Message.ChangedOverrideUser({ value: memberId(7) }), + ]) + expect(overrideIssue(opened.overrideDraft, BERLIN, ONCALL_NOW)).toEqual(Option.none()) + expect( + commandArgs(pageUpdate(opened, Message.ClickedCreateOverride()).commands, "CreateOnCallOverride"), + ).toEqual( + Option.some( + expect.objectContaining({ + payload: { user_id: memberId(7), starts_at: msIso(ONCALL_NOW), ends_at: running.ends_at }, + }), + ), + ) + }) }) diff --git a/apps/web/src/page/alerts/schedule/update.ts b/apps/web/src/page/alerts/schedule/update.ts index 236a971024..5a5ab4bc86 100644 --- a/apps/web/src/page/alerts/schedule/update.ts +++ b/apps/web/src/page/alerts/schedule/update.ts @@ -330,8 +330,9 @@ export const update = (model: Model, message: Message, context: PageContext): Re ClickedPreviousWeek: () => moveWeek(model, context, model.weekOffset - 1), ClickedNextWeek: () => moveWeek(model, context, model.weekOffset + 1), ClickedThisWeek: () => moveWeek(model, context, 0), + // An ended shift has nothing left to cover; a running one is covered from now to its end. ClickedShift: ({ startMs, endMs }) => - canManage(model) ? openOverride(model, startMs, endMs) : { model }, + canManage(model) && endMs > (model.nowMs ?? 0) ? openOverride(model, startMs, endMs) : { model }, ClickedNewOverride: () => { const now = model.nowMs ?? 0 const start = Math.ceil(now / HOUR_MS) * HOUR_MS diff --git a/apps/web/src/page/alerts/schedule/view.ts b/apps/web/src/page/alerts/schedule/view.ts index d7a84d6ec2..51f81d6e59 100644 --- a/apps/web/src/page/alerts/schedule/view.ts +++ b/apps/web/src/page/alerts/schedule/view.ts @@ -77,8 +77,10 @@ const barOf = ( title: `${isOverride ? "Override: " : ""}${name}, ${rangeText(startMs, endMs, schedule.time_zone)}`, tone: isOverride ? "info" : isFinal ? "primary" : "neutral", } - // Only a final shift opens an override, and only for someone who may add one. - return isFinal && canManage(model) ? { ...bar, onClick: Message.ClickedShift({ startMs, endMs }) } : bar + // Only a final shift that has not ended opens an override, and only for someone who may add one. + return isFinal && canManage(model) && endMs > (model.nowMs ?? 0) + ? { ...bar, onClick: Message.ClickedShift({ startMs, endMs }) } + : bar } /** The final row on top, then the layers from the top one down, as they stack. */ From 7f1a1e60c147fb6b93a9bf6d42ea526cd70b25d2 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 15:57:09 +0100 Subject: [PATCH 71/98] fix(web): a refused chain delete counts rules it cannot name instead of calling them hidden --- .../page/alerts/hub/hub-chains.scene.test.ts | 20 +++++++++++ apps/web/src/page/alerts/hub/update.ts | 33 +++++++++++-------- apps/web/src/page/alerts/hub/view-chains.ts | 5 ++- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts index 08609164f5..935377ac5b 100644 --- a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts @@ -156,5 +156,25 @@ describe("alerts hub, escalation chains", () => { "Still used by Checkout errors, Checkout p95. Remove it from their severity tiers first.", ) expect(refused.model.confirmDeleteChainId).toBe(chainId) + + // Before the rules list has loaded the rules cannot be named, only counted. + const unloaded = pageUpdate( + { ...asked.model, rules: null }, + Message.CompletedDeleteChain({ + chainId, + result: Result.fail({ + _tag: "@maple/http/errors/EscalationChainInUseError", + type: "conflict_error", + code: CHAIN_IN_USE_CODE, + title: "Escalation chain is in use", + message: "The escalation chain is used by one or more alert rules.", + retryable: false, + recovery: "fix_request", + }), + }), + ) + expect(unloaded.model.deleteChainError).toBe( + "Alert rules still start this chain. Remove it from their severity tiers first.", + ) }) }) diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index b7d08c077f..ab1d4ad802 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -340,31 +340,36 @@ const liftChainDialog = ( } } -/** The names of the rules a chain says use it, from the synced rules. */ +/** The names of the rules that start `chain` that this tab has loaded; the rest are counted, not named. */ export const chainRuleNames = ( model: Pick, chain: V2EscalationChain, ): ReadonlyArray => - Array.map(chain.rule_ids, (id) => - Option.getOrElse( - Option.map( - Array.findFirst(model.rules ?? [], (row) => row.id === id), - (row) => row.name, - ), - () => "a rule you cannot see", + Array.filterMap(chain.rule_ids, (id) => + Option.match( + Array.findFirst(model.rules ?? [], (row) => row.id === id), + { onNone: () => Result.failVoid, onSome: (row) => Result.succeed(row.name) }, ), ) /** Why a delete was refused: the API's 409 names no rules, so they come from the chain's own list. */ // The cached chain can name no rules when one was attached after this tab read the list. const inUseMessage = (model: Model, chainId: EscalationChainId): string => { - const names = Option.match( - Array.findFirst(chainsOf(model), (chain) => chain.id === chainId), - { onNone: () => [], onSome: (chain) => chainRuleNames(model, chain) }, + const chain = Array.findFirst(chainsOf(model), (candidate) => candidate.id === chainId) + const names = Option.match(chain, { onNone: () => [], onSome: (c) => chainRuleNames(model, c) }) + const total = Math.max( + Option.match(chain, { onNone: () => 0, onSome: (c) => c.rule_ids.length }), + names.length, ) - return names.length === 0 - ? "Alert rules still start this chain. Remove it from their severity tiers first." - : `Still used by ${Array.join(names, ", ")}. Remove it from their severity tiers first.` + const others = total - names.length + const tiers = total === 1 ? "its severity tiers" : "their severity tiers" + if (names.length === 0) { + return total === 1 + ? "An alert rule still starts this chain. Remove it from its severity tiers first." + : `Alert rules still start this chain. Remove it from ${tiers} first.` + } + const listed = others === 0 ? Array.join(names, ", ") : `${Array.join(names, ", ")} and ${others} more` + return `Still used by ${listed}. Remove it from ${tiers} first.` } /** Runs the chain delete dialog's update; `Closed` forgets the target and its error. */ diff --git a/apps/web/src/page/alerts/hub/view-chains.ts b/apps/web/src/page/alerts/hub/view-chains.ts index 4902abbcd4..781a28bbda 100644 --- a/apps/web/src/page/alerts/hub/view-chains.ts +++ b/apps/web/src/page/alerts/hub/view-chains.ts @@ -49,6 +49,7 @@ export const chainRow = ( destinations: ReadonlyArray, ): Html => { const summary = chainSummary(chain, nameOfDestination(destinations)) + const ruleCount = chain.rule_ids.length const ruleNames = chainRuleNames(model, chain) const menuId = chainMenuId(chain.id) const entries: ReadonlyArray = [ @@ -64,9 +65,7 @@ export const chainRow = ( }, ] const usage = - ruleNames.length === 0 - ? "Not used by any rule" - : `Used by ${ruleNames.length} ${pluralize(ruleNames.length, "rule")}` + ruleCount === 0 ? "Not used by any rule" : `Used by ${ruleCount} ${pluralize(ruleCount, "rule")}` return h.li( [h.Key(chain.id), h.Class("px-4 py-3")], [ From 83216686ac46338966c4a0d57071a046cf8fed41 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 16:00:40 +0100 Subject: [PATCH 72/98] test(web): a chain's rule count reads before the rules list loads --- apps/web/src/page/alerts/hub/hub-chains.scene.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts index 935377ac5b..bd3429bee4 100644 --- a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts @@ -95,6 +95,14 @@ describe("alerts hub, escalation chains", () => { ) }) + test("a chain counts the rules that start it before the rules list has loaded", () => { + scene( + { update: pageUpdate, view }, + given({ ...onRouting(ENGINEER, CHAINS), rules: null }), + expectView(text("Used by 2 rules")).toExist(), + ) + }) + test("a member sees the chains but cannot create, edit or delete one", () => { scene( { update: pageUpdate, view }, From de6959d35d5fdaf35c8803bad69dd9d20e917e64 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 16:08:05 +0100 Subject: [PATCH 73/98] fix(api): v2 escalation chain step errors say what is wrong in wire terms An empty or oversized steps or targets array returned 400 "Expected ". The v2 schemas now carry messages, so the error names the field (steps[0].targets) and the limit. --- apps/api/src/routes/v2/alerts.http.test.ts | 42 +++++++++++++++++++ .../domain/src/http/v2/escalation-chains.ts | 17 ++++++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index cb29d3e9c9..071f344d38 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -1485,4 +1485,46 @@ describe("v2 alerts over HTTP", () => { await harness.dispose() }) + + it("names the step field and what is wrong when escalation chain steps are empty or too long", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(["alerts:write"]) + const primary = await createWebhook(harness, key.secret, "primary") + const target = { type: "destination", destination_id: primary.body.id } + const step = { targets: [target], wait_minutes: 10 } + const created = await harness.request("POST", "/v2/alerts/escalation_chains", key.secret, { + name: "Paging", + steps: [step], + }) + expect(created.status, JSON.stringify(created.body)).toBe(200) + const post = (body: unknown) => + harness.request("POST", "/v2/alerts/escalation_chains", key.secret, body) + const patch = (body: unknown) => + harness.request("PATCH", `/v2/alerts/escalation_chains/${created.body.id}`, key.secret, body) + + const responses = await Promise.all([ + post({ name: "Empty", steps: [] }), + post({ name: "No targets", steps: [{ targets: [], wait_minutes: 10 }] }), + post({ name: "Long", steps: Arr.makeBy(11, () => step) }), + post({ name: "Wide", steps: [{ targets: Arr.makeBy(11, () => target), wait_minutes: 10 }] }), + patch({ steps: [] }), + patch({ steps: [step, { targets: [], wait_minutes: 5 }] }), + ]) + expect( + Arr.map(responses, (response) => [ + response.status, + response.body.error.param, + response.body.error.message, + ]), + ).toEqual([ + [400, "steps", "steps: needs at least one step"], + [400, "steps[0].targets", "steps[0].targets: needs at least one target"], + [400, "steps", "steps: allows at most 10 steps"], + [400, "steps[0].targets", "steps[0].targets: allows at most 10 targets per step"], + [400, "steps", "steps: needs at least one step"], + [400, "steps[1].targets", "steps[1].targets: needs at least one target"], + ]) + + await harness.dispose() + }) }) diff --git a/packages/domain/src/http/v2/escalation-chains.ts b/packages/domain/src/http/v2/escalation-chains.ts index 786633b7d9..52c8a4a3db 100644 --- a/packages/domain/src/http/v2/escalation-chains.ts +++ b/packages/domain/src/http/v2/escalation-chains.ts @@ -37,9 +37,15 @@ export type V2EscalationTarget = Schema.Schema.Type // Bounded arrays on the wire, not tuples: generated clients do not all read `prefixItems`. // The refine narrows the decoded type to non-empty and goes first, so the checks carry the docs. +// Messages are wire terms: the v2 boundary prefixes them with the field path. const V2EscalationTargets = Schema.Array(V2EscalationTarget) - .pipe(Schema.refine(Arr.isReadonlyArrayNonEmpty)) - .check(Schema.isMinLength(1), Schema.isMaxLength(MAX_ESCALATION_STEP_TARGETS)) + .pipe(Schema.refine(Arr.isReadonlyArrayNonEmpty, { message: "needs at least one target" })) + .check( + Schema.isMinLength(1), + Schema.isMaxLength(MAX_ESCALATION_STEP_TARGETS, { + message: `allows at most ${MAX_ESCALATION_STEP_TARGETS} targets per step`, + }), + ) export const V2EscalationStep = Schema.Struct({ targets: V2EscalationTargets.annotate({ @@ -57,8 +63,11 @@ export const V2EscalationStep = Schema.Struct({ export type V2EscalationStep = Schema.Schema.Type const V2EscalationSteps = Schema.Array(V2EscalationStep) - .pipe(Schema.refine(Arr.isReadonlyArrayNonEmpty)) - .check(Schema.isMinLength(1), Schema.isMaxLength(MAX_ESCALATION_STEPS)) + .pipe(Schema.refine(Arr.isReadonlyArrayNonEmpty, { message: "needs at least one step" })) + .check( + Schema.isMinLength(1), + Schema.isMaxLength(MAX_ESCALATION_STEPS, { message: `allows at most ${MAX_ESCALATION_STEPS} steps` }), + ) .annotate({ description: `The steps in order, 1 to ${MAX_ESCALATION_STEPS}.` }) const RepeatCount = EscalationRepeatCount.annotate({ From 254950ee538a2c4f00456cf75dc6165518258ecd Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 16:08:05 +0100 Subject: [PATCH 74/98] fix(alerts): destination in-use conflict names escalation chains too Deleting a destination only an escalation chain uses returned a 409 that blamed alert rules. --- apps/api/src/routes/v2/alerts.http.test.ts | 26 ++++++++++++++++++++++ packages/domain/src/http/alerts.ts | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index 071f344d38..c2f95c4e43 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -1527,4 +1527,30 @@ describe("v2 alerts over HTTP", () => { await harness.dispose() }) + + it("refuses to delete a destination only an escalation chain uses, naming chains in the conflict", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(["alerts:write"]) + const primary = await createWebhook(harness, key.secret, "primary") + const chain = await harness.request("POST", "/v2/alerts/escalation_chains", key.secret, { + name: "Chain only", + steps: [ + { targets: [{ type: "destination", destination_id: primary.body.id }], wait_minutes: 10 }, + ], + }) + expect(chain.status, JSON.stringify(chain.body)).toBe(200) + + const conflicted = await harness.request( + "DELETE", + `/v2/alerts/destinations/${primary.body.id}`, + key.secret, + ) + expect([conflicted.status, conflicted.body.error.code, conflicted.body.error.message]).toEqual([ + 409, + "alert_destination_in_use", + "The alert destination is currently used by one or more alert rules or escalation chains.", + ]) + + await harness.dispose() + }) }) diff --git a/packages/domain/src/http/alerts.ts b/packages/domain/src/http/alerts.ts index a1655a0545..3898a8a951 100644 --- a/packages/domain/src/http/alerts.ts +++ b/packages/domain/src/http/alerts.ts @@ -1489,7 +1489,7 @@ export class AlertDestinationInUseError extends HttpTaggedError Date: Fri, 6 Nov 2026 16:08:14 +0100 Subject: [PATCH 75/98] fix(mcp): escalation chain tools report bad fields on their parameter and show REST ids in list create/update_escalation_chain mapped every request decode failure to steps; a bad repeat_count, name or description now names that parameter in tool spelling. list_escalation_chains renders the same esc_ and dest_ ids as create, and target rows carry destinationPublicId. --- apps/ai/src/mcp/lib/escalation-chains.ts | 52 ++++++++++++++++--- apps/ai/src/mcp/tool-descriptors.generated.ts | 6 +-- .../__tests__/escalation-chain-tools.test.ts | 52 +++++++++++++++++-- .../src/mcp/tools/create-escalation-chain.ts | 13 ++--- .../src/mcp/tools/list-escalation-chains.ts | 14 +---- .../src/mcp/tools/update-escalation-chain.ts | 11 +--- .../src/mcp-outputs/escalation-chains.ts | 2 + 7 files changed, 106 insertions(+), 44 deletions(-) diff --git a/apps/ai/src/mcp/lib/escalation-chains.ts b/apps/ai/src/mcp/lib/escalation-chains.ts index bb4067e608..0a24b89704 100644 --- a/apps/ai/src/mcp/lib/escalation-chains.ts +++ b/apps/ai/src/mcp/lib/escalation-chains.ts @@ -1,5 +1,5 @@ /** Pieces the escalation chain MCP tools share: rows, step text, step input and error mapping. */ -import { Array as Arr, Effect, Option, Schema } from "effect" +import { Array as Arr, Effect, Option, Schema, SchemaIssue } from "effect" import type { AlertDestinationDocument, AlertForbiddenError, @@ -73,6 +73,7 @@ export const toEscalationChainRow = ( ) return { destinationId: target.destinationId, + destinationPublicId: encodePublicId(PublicIdPrefixes.alertDestination, target.destinationId), name: known?.name ?? null, type: known?.type ?? null, } @@ -94,13 +95,13 @@ const formatWait = (minutes: number): string => const targetLabel = (target: ChainRow["steps"][number]["targets"][number]): string => target.name === null - ? `destination ${target.destinationId}` + ? `destination ${target.destinationPublicId}` : target.type === null - ? target.name - : `${target.name} (${target.type})` + ? `${target.name} (${target.destinationPublicId})` + : `${target.name} (${target.type}, ${target.destinationPublicId})` -/** One line per step, e.g. `1. notify On-call (pagerduty), then wait 10m for an ack`. */ -export const formatSteps = (chain: ChainRow): string => +/** One line per step, e.g. `1. notify On-call (pagerduty, dest_…), then wait 10m for an ack`. */ +const formatSteps = (chain: ChainRow): string => Arr.join( Arr.map( chain.steps, @@ -110,7 +111,7 @@ export const formatSteps = (chain: ChainRow): string => "\n", ) -export const formatRepeats = (repeatCount: number): string => +const formatRepeats = (repeatCount: number): string => repeatCount === 0 ? "runs once" : `repeats ${repeatCount} more time${repeatCount === 1 ? "" : "s"}` export const chainBlocks = (chain: ChainRow) => [ @@ -131,6 +132,43 @@ export const chainNotFound = (chainId: string) => parameter: "chain_id", }) +const formatIssues = SchemaIssue.makeFormatterStandardSchemaV1() + +/** A service request key as the tool spells it: a parameter, or a key inside `steps`. */ +const toolKey = (segment: PropertyKey): string => { + const key = String(segment) + switch (key) { + case "repeatCount": + return "repeat_count" + case "waitMinutes": + return "wait_minutes" + case "targets": + return "destination_ids" + default: + return key + } +} + +/** A request that failed to decode, reported against the parameter its first issue is under. */ +export const invalidChainRequest = (error: Schema.SchemaError): McpInvalidInputError => { + const first = formatIssues(error.issue).issues[0] + const path = Arr.map(first?.path ?? [], (segment) => + typeof segment === "object" && segment !== null ? segment.key : segment, + ) + const [root] = path + const location = Arr.join( + Arr.map(path, (segment, index) => + typeof segment === "number" ? `[${segment}]` : `${index === 0 ? "" : "."}${toolKey(segment)}`, + ), + "", + ) + const message = first?.message ?? String(error) + return new McpInvalidInputError({ + message: location === "" ? `Invalid escalation chain: ${message}` : `${location}: ${message}`, + parameter: root === undefined ? "steps" : toolKey(root), + }) +} + const failWith = (pipeName: string) => (error: AlertForbiddenError | AlertPersistenceError) => Effect.fail(toMcpHttpError(pipeName)(error)) diff --git a/apps/ai/src/mcp/tool-descriptors.generated.ts b/apps/ai/src/mcp/tool-descriptors.generated.ts index 4424830019..8f64ca2dd8 100644 --- a/apps/ai/src/mcp/tool-descriptors.generated.ts +++ b/apps/ai/src/mcp/tool-descriptors.generated.ts @@ -38,9 +38,9 @@ export const MCP_TOOL_DESCRIPTORS: ReadonlyArray = [ {"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"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"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}}"},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of the destinations (from list_escalation_chains), or \"none\" to detach. Applies to escalation_chain_tier; without it, an id sets the critical (or only) tier and \"none\" detaches every tier: its UUID or its REST id (esc_\u2026)"},"escalation_chain_tier":{"type":"string","enum":["warning","critical"],"description":"The tier escalation_chain_id sets or detaches, leaving the other tier's chain as it is"}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"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":"acknowledge_alert_incident","title":"Acknowledge Alert Incident","description":"Mark an open alert incident as acknowledged (someone is on it) or, with acknowledged=false, clear the acknowledgement. While acknowledged, reminders pause; an escalation to a higher severity still notifies and clears it. Only open incidents; ids from list_alert_incidents.","inputSchema":{"type":"object","properties":{"incident_id":{"type":"string","description":"Alert incident ID: its UUID or its REST id (inc_\u2026)"},"acknowledged":{"type":"boolean","description":"false to unacknowledge. Default true."}},"required":["incident_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"ruleId":{"type":"string"},"ruleName":{"type":"string"},"status":{"type":"string"},"acknowledged":{"type":"boolean"},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"anyOf":[{"type":"string"},{"type":"null"}]},"changed":{"type":"boolean"}},"required":["id","ruleId","ruleName","status","acknowledged","acknowledgedAt","acknowledgedBy","changed"],"additionalProperties":true},"annotations":{"title":"Acknowledge Alert Incident","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, - {"name":"list_escalation_chains","title":"List Escalation Chains","description":"List escalation chains: ordered paging steps that notify destinations one step at a time until an incident is acknowledged or resolved. Shows each chain's steps, repeats and the rules using it. Attach one to a rule with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chains":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["chains","total"],"additionalProperties":true},"annotations":{"title":"List Escalation Chains","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, - {"name":"create_escalation_chain","title":"Create Escalation Chain","description":"Create an escalation chain: steps that each notify their destinations, then wait for an acknowledgement before the next step. A rule tier that names the chain still notifies its own destinations at once; the chain pages on top until the incident is acknowledged or resolved. Attach it with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"Chain name, e.g. 'Checkout on-call'"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["destination_ids","wait_minutes"],"additionalProperties":false}},"description":"Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":10},{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. At most 10 steps of 10 destinations"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5 (default 0)"},"description":{"type":"string","description":"Free-form notes"}},"required":["name","steps"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Create Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, - {"name":"update_escalation_chain","title":"Update Escalation Chain","description":"Change an escalation chain's name, steps, repeats or description. Pass only what changes; steps replaces every step. Escalations already running finish on the steps they started with. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to change: its UUID or its REST id (esc_\u2026)"},"name":{"type":"string","description":"New name"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["destination_ids","wait_minutes"],"additionalProperties":false}},"description":"Replaces every step. Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":10},{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. At most 10 steps of 10 destinations"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5"},"description":{"type":"string","description":"New notes"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Update Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"list_escalation_chains","title":"List Escalation Chains","description":"List escalation chains: ordered paging steps that notify destinations one step at a time until an incident is acknowledged or resolved. Shows each chain's steps, repeats and the rules using it. Attach one to a rule with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chains":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"destinationPublicId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","destinationPublicId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["chains","total"],"additionalProperties":true},"annotations":{"title":"List Escalation Chains","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"create_escalation_chain","title":"Create Escalation Chain","description":"Create an escalation chain: steps that each notify their destinations, then wait for an acknowledgement before the next step. A rule tier that names the chain still notifies its own destinations at once; the chain pages on top until the incident is acknowledged or resolved. Attach it with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"Chain name, e.g. 'Checkout on-call'"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["destination_ids","wait_minutes"],"additionalProperties":false}},"description":"Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":10},{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. At most 10 steps of 10 destinations"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5 (default 0)"},"description":{"type":"string","description":"Free-form notes"}},"required":["name","steps"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"destinationPublicId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","destinationPublicId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Create Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, + {"name":"update_escalation_chain","title":"Update Escalation Chain","description":"Change an escalation chain's name, steps, repeats or description. Pass only what changes; steps replaces every step. Escalations already running finish on the steps they started with. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to change: its UUID or its REST id (esc_\u2026)"},"name":{"type":"string","description":"New name"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["destination_ids","wait_minutes"],"additionalProperties":false}},"description":"Replaces every step. Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":10},{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. At most 10 steps of 10 destinations"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5"},"description":{"type":"string","description":"New notes"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"destinationPublicId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","destinationPublicId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Update Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"delete_escalation_chain","title":"Delete Escalation Chain","description":"Delete an escalation chain. Refused while a rule tier still names it: detach it first with update_alert_rule escalation_chain_id=\"none\" and escalation_chain_tier naming the tier, so another tier's chain stays; a tier that pages only through the chain needs a destination before that (add_destination_ids for a one-tier rule or the warning tier; a critical tier via the rule's thresholds in the v2 API or dashboard). Escalations already running finish. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to delete: its UUID or its REST id (esc_\u2026)"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"additionalProperties":true},"annotations":{"title":"Delete Escalation Chain","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"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}}, diff --git a/apps/ai/src/mcp/tools/__tests__/escalation-chain-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/escalation-chain-tools.test.ts index fc8168b302..9423e8073c 100644 --- a/apps/ai/src/mcp/tools/__tests__/escalation-chain-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/escalation-chain-tools.test.ts @@ -169,11 +169,57 @@ describe("escalation chain tools", () => { const output = Schema.decodeUnknownSync(ListEscalationChainsOutput)(result.structuredContent) expect(output.chains[0]?.publicId).toBe(encodePublicId("esc", CHAIN_ID)) expect(output.chains[0]?.steps[1]?.targets[0]?.name).toBe("Ops channel") - expect(text(result)).toContain("1. notify Primary pager (pagerduty), then wait 10m for an ack") - expect(text(result)).toContain("2. notify Ops channel (webhook), then wait 1h 30m for an ack") + expect(text(result)).toContain( + `1. notify Primary pager (pagerduty, ${encodePublicId("dest", PRIMARY)}), then wait 10m for an ack`, + ) + expect(text(result)).toContain( + `2. notify Ops channel (webhook, ${encodePublicId("dest", SECONDARY)}), then wait 1h 30m for an ack`, + ) expect(text(result)).toContain("repeats 1 more time") }) + it("list_escalation_chains shows the esc_ and dest_ ids create shows", async () => { + const listed = await ok("list_escalation_chains", {}) + const created = await ok("create_escalation_chain", { + name: "Checkout on-call", + steps: JSON.stringify([{ destination_ids: [PRIMARY], wait_minutes: 10 }]), + }) + const output = Schema.decodeUnknownSync(ListEscalationChainsOutput)(listed.structuredContent) + expect(output.chains[0]?.steps[0]?.targets[0]?.destinationPublicId).toBe( + encodePublicId("dest", PRIMARY), + ) + for (const result of [listed, created]) { + expect(text(result)).toContain(encodePublicId("esc", CHAIN_ID)) + expect(text(result)).toContain(CHAIN_ID) + expect(text(result)).toContain(encodePublicId("dest", PRIMARY)) + } + }) + + it("create and update report a bad field against the parameter it came from", async () => { + const step = [{ destination_ids: [PRIMARY], wait_minutes: 10 }] + const failures = await Promise.all([ + run("create_escalation_chain", { name: "Loop", steps: step, repeat_count: 6 }, {}), + run("create_escalation_chain", { name: " padded ", steps: step }, {}), + run("create_escalation_chain", { name: "Notes", steps: step, description: "x".repeat(501) }, {}), + run("create_escalation_chain", { name: "Slow", steps: [{ ...step[0], wait_minutes: 0 }] }, {}), + run("update_escalation_chain", { chain_id: CHAIN_ID, repeat_count: 6 }, {}), + run("update_escalation_chain", { chain_id: CHAIN_ID, name: "x".repeat(121) }, {}), + ]) + expect(Arr.map(failures, invalidParameter)).toEqual([ + "repeat_count", + "name", + "description", + "steps", + "repeat_count", + "name", + ]) + const messages = Arr.map(failures, (result) => + result._tag === "Failure" ? result.failure.message : "", + ) + expect(messages[0]).toContain("repeat_count: Expected a value less than or equal to 5") + expect(messages[3]).toContain("steps[0].wait_minutes: Expected a value greater than or equal to 1") + }) + it("create_escalation_chain decodes REST destination ids and JSON steps into the request", async () => { const seen: Seen = {} const result = await ok( @@ -200,7 +246,7 @@ describe("escalation chain tools", () => { ]) expect(seen.created?.repeatCount).toBe(2) expect(text(result)).toContain( - "2. notify Ops channel (webhook), Primary pager (pagerduty), then wait 15m", + `2. notify Ops channel (webhook, ${encodePublicId("dest", SECONDARY)}), Primary pager (pagerduty, ${encodePublicId("dest", PRIMARY)}), then wait 15m`, ) }) diff --git a/apps/ai/src/mcp/tools/create-escalation-chain.ts b/apps/ai/src/mcp/tools/create-escalation-chain.ts index f1742a689d..8e352fea75 100644 --- a/apps/ai/src/mcp/tools/create-escalation-chain.ts +++ b/apps/ai/src/mcp/tools/create-escalation-chain.ts @@ -1,4 +1,4 @@ -import { McpInvalidInputError, type McpToolRegistrar } from "./types" +import type { McpToolRegistrar } from "./types" import { Effect, Schema } from "effect" import { EscalationChainCreateRequest } from "@maple/domain/http" import { EscalationChainMutationOutput } from "@maple/domain/mcp-outputs" @@ -8,6 +8,7 @@ import { chainBlocks, createChainErrors, destinationDirectory, + invalidChainRequest, StepsInput, stepsParameterDescription, toEscalationChainRow, @@ -44,15 +45,7 @@ export function registerCreateEscalationChainTool(server: McpToolRegistrar) { steps, ...(params.repeat_count === undefined ? undefined : { repeatCount: params.repeat_count }), ...(params.description === undefined ? undefined : { description: params.description }), - }).pipe( - Effect.mapError( - (error) => - new McpInvalidInputError({ - message: `Invalid escalation chain: ${String(error)}`, - parameter: "steps", - }), - ), - ) + }).pipe(Effect.mapError(invalidChainRequest)) const tenant = yield* CurrentMcpTenant const chains = yield* EscalationChainsService const chain = yield* chains diff --git a/apps/ai/src/mcp/tools/list-escalation-chains.ts b/apps/ai/src/mcp/tools/list-escalation-chains.ts index 11594e75b0..50b49ad295 100644 --- a/apps/ai/src/mcp/tools/list-escalation-chains.ts +++ b/apps/ai/src/mcp/tools/list-escalation-chains.ts @@ -4,12 +4,7 @@ import { ListEscalationChainsOutput } from "@maple/domain/mcp-outputs" import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" import { CurrentMcpTenant } from "../lib/query-warehouse" import { toMcpHttpError } from "../lib/map-http-error" -import { - destinationDirectory, - formatRepeats, - formatSteps, - toEscalationChainRow, -} from "../lib/escalation-chains" +import { chainBlocks, destinationDirectory, toEscalationChainRow } from "../lib/escalation-chains" import { doc } from "../lib/tool-doc" export function registerListEscalationChainsTool(server: McpToolRegistrar) { @@ -49,12 +44,7 @@ export function registerListEscalationChainsTool(server: McpToolRegistrar) { }, } : undefined), - blocks: Arr.flatMap(output.chains, (chain) => [ - doc.heading(`${chain.name} (${chain.id})`), - doc.text( - `${formatSteps(chain)}\nThe chain ${formatRepeats(chain.repeatCount)}. Used by ${chain.ruleIds.length} rule${chain.ruleIds.length === 1 ? "" : "s"}.`, - ), - ]), + blocks: Arr.flatMap(output.chains, (chain) => [doc.heading(chain.name), ...chainBlocks(chain)]), next: output.chains.length === 0 ? [doc.next("list_alert_destinations", {}, "the destinations a step can notify")] diff --git a/apps/ai/src/mcp/tools/update-escalation-chain.ts b/apps/ai/src/mcp/tools/update-escalation-chain.ts index df29eb0a9a..801e988672 100644 --- a/apps/ai/src/mcp/tools/update-escalation-chain.ts +++ b/apps/ai/src/mcp/tools/update-escalation-chain.ts @@ -7,6 +7,7 @@ import { CurrentMcpTenant } from "../lib/query-warehouse" import { chainBlocks, destinationDirectory, + invalidChainRequest, StepsInput, stepsParameterDescription, toEscalationChainRow, @@ -54,15 +55,7 @@ export function registerUpdateEscalationChainTool(server: McpToolRegistrar) { ...(steps === undefined ? undefined : { steps }), ...(params.repeat_count === undefined ? undefined : { repeatCount: params.repeat_count }), ...(params.description === undefined ? undefined : { description: params.description }), - }).pipe( - Effect.mapError( - (error) => - new McpInvalidInputError({ - message: `Invalid escalation chain: ${String(error)}`, - parameter: "steps", - }), - ), - ) + }).pipe(Effect.mapError(invalidChainRequest)) const tenant = yield* CurrentMcpTenant const chains = yield* EscalationChainsService const chain = yield* chains diff --git a/packages/domain/src/mcp-outputs/escalation-chains.ts b/packages/domain/src/mcp-outputs/escalation-chains.ts index f36193ef1f..a8ac177161 100644 --- a/packages/domain/src/mcp-outputs/escalation-chains.ts +++ b/packages/domain/src/mcp-outputs/escalation-chains.ts @@ -5,6 +5,8 @@ const NullableString = Schema.NullOr(Schema.String) export const EscalationTargetRow = Schema.Struct({ destinationId: Schema.String, + /** The same destination's id in the REST API (`dest_…`). */ + destinationPublicId: Schema.String, /** Null when the destination list could not be read or the destination is gone. */ name: NullableString, type: NullableString, From 2c1ffc630d5bb24f60cc8f0d21e526ac07c5bcd2 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 16:19:51 +0100 Subject: [PATCH 76/98] fix(mcp): update_escalation_chain clears notes with description "none" --- apps/ai/src/mcp/tool-descriptors.generated.ts | 2 +- .../tools/__tests__/escalation-chain-tools.test.ts | 8 ++++++++ apps/ai/src/mcp/tools/update-escalation-chain.ts | 14 +++++++++++--- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/ai/src/mcp/tool-descriptors.generated.ts b/apps/ai/src/mcp/tool-descriptors.generated.ts index 8f64ca2dd8..4b215f94d5 100644 --- a/apps/ai/src/mcp/tool-descriptors.generated.ts +++ b/apps/ai/src/mcp/tool-descriptors.generated.ts @@ -40,7 +40,7 @@ export const MCP_TOOL_DESCRIPTORS: ReadonlyArray = [ {"name":"acknowledge_alert_incident","title":"Acknowledge Alert Incident","description":"Mark an open alert incident as acknowledged (someone is on it) or, with acknowledged=false, clear the acknowledgement. While acknowledged, reminders pause; an escalation to a higher severity still notifies and clears it. Only open incidents; ids from list_alert_incidents.","inputSchema":{"type":"object","properties":{"incident_id":{"type":"string","description":"Alert incident ID: its UUID or its REST id (inc_\u2026)"},"acknowledged":{"type":"boolean","description":"false to unacknowledge. Default true."}},"required":["incident_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"ruleId":{"type":"string"},"ruleName":{"type":"string"},"status":{"type":"string"},"acknowledged":{"type":"boolean"},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"anyOf":[{"type":"string"},{"type":"null"}]},"changed":{"type":"boolean"}},"required":["id","ruleId","ruleName","status","acknowledged","acknowledgedAt","acknowledgedBy","changed"],"additionalProperties":true},"annotations":{"title":"Acknowledge Alert Incident","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"list_escalation_chains","title":"List Escalation Chains","description":"List escalation chains: ordered paging steps that notify destinations one step at a time until an incident is acknowledged or resolved. Shows each chain's steps, repeats and the rules using it. Attach one to a rule with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chains":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"destinationPublicId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","destinationPublicId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["chains","total"],"additionalProperties":true},"annotations":{"title":"List Escalation Chains","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"create_escalation_chain","title":"Create Escalation Chain","description":"Create an escalation chain: steps that each notify their destinations, then wait for an acknowledgement before the next step. A rule tier that names the chain still notifies its own destinations at once; the chain pages on top until the incident is acknowledged or resolved. Attach it with create_alert_rule / update_alert_rule escalation_chain_id.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"Chain name, e.g. 'Checkout on-call'"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["destination_ids","wait_minutes"],"additionalProperties":false}},"description":"Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":10},{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. At most 10 steps of 10 destinations"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5 (default 0)"},"description":{"type":"string","description":"Free-form notes"}},"required":["name","steps"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"destinationPublicId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","destinationPublicId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Create Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, - {"name":"update_escalation_chain","title":"Update Escalation Chain","description":"Change an escalation chain's name, steps, repeats or description. Pass only what changes; steps replaces every step. Escalations already running finish on the steps they started with. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to change: its UUID or its REST id (esc_\u2026)"},"name":{"type":"string","description":"New name"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["destination_ids","wait_minutes"],"additionalProperties":false}},"description":"Replaces every step. Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":10},{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. At most 10 steps of 10 destinations"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5"},"description":{"type":"string","description":"New notes"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"destinationPublicId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","destinationPublicId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Update Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"update_escalation_chain","title":"Update Escalation Chain","description":"Change an escalation chain's name, steps, repeats or description. Pass only what changes; steps replaces every step. Escalations already running finish on the steps they started with. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to change: its UUID or its REST id (esc_\u2026)"},"name":{"type":"string","description":"New name"},"steps":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destinations this step notifies, from list_alert_destinations (UUID or dest_\u2026)"},"wait_minutes":{"type":"number","description":"Minutes to wait for an acknowledgement before the next step, 1 to 1440"}},"required":["destination_ids","wait_minutes"],"additionalProperties":false}},"description":"Replaces every step. Steps in order, each notifying its destinations then waiting for an ack before the next, e.g. [{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":10},{\"destination_ids\":[\"dest_\u2026\"],\"wait_minutes\":15}]. At most 10 steps of 10 destinations"},"repeat_count":{"type":"number","description":"Extra passes over the whole chain after the first, 0 to 5"},"description":{"type":"string","description":"New notes, or \"none\" to clear them"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"chain":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"steps":{"type":"array","items":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"destinationId":{"type":"string"},"destinationPublicId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["destinationId","destinationPublicId","name","type"],"additionalProperties":true}},"waitMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["targets","waitMinutes"],"additionalProperties":true}},"repeatCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","description","steps","repeatCount","ruleIds","createdAt","updatedAt"],"additionalProperties":true}},"required":["chain"],"additionalProperties":true},"annotations":{"title":"Update Escalation Chain","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"delete_escalation_chain","title":"Delete Escalation Chain","description":"Delete an escalation chain. Refused while a rule tier still names it: detach it first with update_alert_rule escalation_chain_id=\"none\" and escalation_chain_tier naming the tier, so another tier's chain stays; a tier that pages only through the chain needs a destination before that (add_destination_ids for a one-tier rule or the warning tier; a critical tier via the rule's thresholds in the v2 API or dashboard). Escalations already running finish. Ids from list_escalation_chains.","inputSchema":{"type":"object","properties":{"chain_id":{"type":"string","description":"Escalation chain to delete: its UUID or its REST id (esc_\u2026)"}},"required":["chain_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"additionalProperties":true},"annotations":{"title":"Delete Escalation Chain","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"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}}, diff --git a/apps/ai/src/mcp/tools/__tests__/escalation-chain-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/escalation-chain-tools.test.ts index 9423e8073c..de19414a12 100644 --- a/apps/ai/src/mcp/tools/__tests__/escalation-chain-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/escalation-chain-tools.test.ts @@ -283,6 +283,14 @@ describe("escalation chain tools", () => { ) expect(seen.updated).toEqual({ name: "Renamed" }) + // "none" clears the notes; any other text sets them. + const cleared: Seen = {} + await ok("update_escalation_chain", { chain_id: CHAIN_ID, description: "none" }, cleared) + expect(cleared.updated).toEqual({ description: null }) + const noted: Seen = {} + await ok("update_escalation_chain", { chain_id: CHAIN_ID, description: "Weekend coverage" }, noted) + expect(noted.updated).toEqual({ description: "Weekend coverage" }) + const nothing = await run("update_escalation_chain", { chain_id: CHAIN_ID }, {}) expect(invalidParameter(nothing)).toBe("steps") const missing = await run("update_escalation_chain", { chain_id: MISSING_ID, repeat_count: 0 }, {}) diff --git a/apps/ai/src/mcp/tools/update-escalation-chain.ts b/apps/ai/src/mcp/tools/update-escalation-chain.ts index 801e988672..a973fd9883 100644 --- a/apps/ai/src/mcp/tools/update-escalation-chain.ts +++ b/apps/ai/src/mcp/tools/update-escalation-chain.ts @@ -1,5 +1,5 @@ import { McpInvalidInputError, type McpToolRegistrar } from "./types" -import { Effect, Schema } from "effect" +import { Effect, Schema, String as Str } from "effect" import { EscalationChainUpdateRequest } from "@maple/domain/http" import { EscalationChainMutationOutput } from "@maple/domain/mcp-outputs" import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" @@ -31,7 +31,7 @@ export function registerUpdateEscalationChainTool(server: McpToolRegistrar) { name: P.optionalText("New name"), steps: P.optionalJson(StepsInput, `Replaces every step. ${stepsParameterDescription}`), repeat_count: P.optionalNumber("Extra passes over the whole chain after the first, 0 to 5"), - description: P.optionalText("New notes"), + description: P.optionalText('New notes, or "none" to clear them'), }), output: EscalationChainMutationOutput, hints: { readOnly: false, destructive: false, idempotent: true }, @@ -54,7 +54,15 @@ export function registerUpdateEscalationChainTool(server: McpToolRegistrar) { ...(params.name === undefined ? undefined : { name: params.name }), ...(steps === undefined ? undefined : { steps }), ...(params.repeat_count === undefined ? undefined : { repeatCount: params.repeat_count }), - ...(params.description === undefined ? undefined : { description: params.description }), + ...(params.description === undefined + ? undefined + : // "none" clears the notes, as escalation_chain_id does on the rule tools. + { + description: + Str.toLowerCase(Str.trim(params.description)) === "none" + ? null + : params.description, + }), }).pipe(Effect.mapError(invalidChainRequest)) const tenant = yield* CurrentMcpTenant const chains = yield* EscalationChainsService From 1352903dd4004aa523ba26dbc7b6c034429f5af7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 19:16:46 +0100 Subject: [PATCH 77/98] feat(alerts): a silence holds an escalation's due step until it ends A due step first asks whether a silence covers the incident (one rule read, only when a step would go out) and defers under one, rechecking every minute. An incident that opens under a silence still starts its chain; step 1 waits. When the silence ends, the catch-up page re-keys the chain's due step to its own time, so a destination in both the tier and the step is paged once. --- .../alerts/AlertsService.escalation.test.ts | 112 +++++++++++++++++- .../src/services/alerts/AlertsService.ts | 53 ++++++++- .../services/alerts/alert-escalation-runs.ts | 28 ++++- 3 files changed, 190 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/services/alerts/AlertsService.escalation.test.ts b/packages/backend/src/services/alerts/AlertsService.escalation.test.ts index 61e75dd01e..c1f96df927 100644 --- a/packages/backend/src/services/alerts/AlertsService.escalation.test.ts +++ b/packages/backend/src/services/alerts/AlertsService.escalation.test.ts @@ -1,11 +1,13 @@ import { AlertDestinationInUseError, AlertRuleUpsertRequest, + AlertSilenceCreateRequest, AlertValidationError, EscalationChainCreateRequest, EscalationChainId, EscalationChainInUseError, EscalationChainUpdateRequest, + IsoDateTimeString, WebhookAlertDestinationConfig, type AlertDestinationId, type AlertThreshold, @@ -13,11 +15,12 @@ import { type UserId, } from "@maple/domain/http" import { afterEach, assert, describe, it } from "@effect/vitest" -import { Duration, Effect, Layer, Schema, Array as Arr, String as Str } from "effect" +import { Clock, Duration, Effect, Layer, Schema, Array as Arr, String as Str } from "effect" import { TestClock } from "effect/testing" import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@maple/backend/platform/test-pglite" import { Database } from "@maple/backend/platform/DatabaseLive" import { AlertsService, type AlertsServiceApi } from "./AlertsService" +import { AlertSilencesService } from "./AlertSilencesService" import { applyEscalationPlan, planEscalationWork, selectEscalationWork } from "./alert-escalation-runs" import { EscalationChainsService } from "./EscalationChainsService" import { @@ -32,6 +35,7 @@ import { } from "./alerts-service-test-support" const decodeChainId = Schema.decodeUnknownSync(EscalationChainId) +const decodeIso = Schema.decodeUnknownSync(IsoDateTimeString) const trackedDbs: TestDb[] = [] afterEach(() => cleanupTestDbs(trackedDbs)) @@ -174,6 +178,24 @@ const setAck = (testDb: TestDb, orgId: OrgId, acknowledged: boolean) => ), ) +/** Silences the `checkout` service for an hour; ended early with `silences.end`. */ +const silenceCheckout = (orgId: OrgId, userId: UserId) => + Effect.gen(function* () { + const silences = yield* AlertSilencesService + const nowMs = yield* Clock.currentTimeMillis + const silence = yield* silences.create( + orgId, + userId, + adminRoles, + new AlertSilenceCreateRequest({ + reason: "checkout deploy", + matchers: { services: ["checkout"] }, + endsAt: decodeIso(new Date(nowMs + 60 * 60_000).toISOString()), + }), + ) + return { end: silences.end(orgId, userId, adminRoles, silence.id) } + }) + describe("escalation chains", () => { it.effect("pages the next step after the wait, once, and ends after the last step", () => { const testDb = createTestDb(trackedDbs) @@ -448,6 +470,94 @@ describe("escalation chains", () => { }).pipe(Effect.provide(layerFor(testDb, state, requests))) }) + it.effect("holds a step that falls due under a silence, and sends it once the silence ends", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRateRows(10) } + const requests: Array = [] + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const { alerts, orgId, userId } = yield* setup("org_esc_silenced_step") + yield* alerts.runSchedulerTick() + yield* tick(alerts) + assert.sameMembers(requests, ["trigger team", "trigger slack"]) + const silence = yield* silenceCheckout(orgId, userId) + // The pager step falls due at minute 10; the silence holds it. + yield* ticks(alerts, 15) + assert.lengthOf(requests, 2) + assert.strictEqual((yield* runRows(testDb, orgId))[0]?.status, "active") + + yield* silence.end + yield* tick(alerts) + assert.deepStrictEqual(requests.slice(2), ["trigger pager"]) + yield* ticks(alerts, 5) + assert.deepStrictEqual(requests.slice(2), ["trigger pager"]) + }).pipe(Effect.provide(layerFor(testDb, state, requests))) + }) + + it.effect( + "starts the chain of an incident that opens under a silence, paging step 1 once it ends", + () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRateRows(10) } + const requests: Array = [] + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const { alerts, orgId, userId } = yield* setup("org_esc_opened_silenced") + const silence = yield* silenceCheckout(orgId, userId) + yield* alerts.runSchedulerTick() + yield* ticks(alerts, 5) + assert.deepStrictEqual(requests, []) + assert.deepStrictEqual(yield* runRows(testDb, orgId), [ + { status: "active", steps_sent: 0, chain_name: "API escalation" }, + ]) + + yield* silence.end + yield* tick(alerts) + // The tier's held-back trigger and the chain's first step, each once. + assert.sameMembers(requests, ["trigger team", "trigger slack"]) + yield* ticks(alerts, 5) + assert.sameMembers(requests, ["trigger team", "trigger slack"]) + }).pipe(Effect.provide(layerFor(testDb, state, requests))) + }, + ) + + it.effect("pages a destination in both the tier and the held step once when the silence ends", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRateRows(10) } + const requests: Array = [] + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const alerts = yield* AlertsService + const chains = yield* EscalationChainsService + const orgId = asOrgId("org_esc_silenced_dedupe") + const userId = asUserId("user_esc_silenced_dedupe") + const team = yield* webhook(alerts, orgId, userId, "team") + const chain = yield* chains.create( + orgId, + userId, + adminRoles, + new EscalationChainCreateRequest({ + name: "Team first", + steps: [{ targets: [{ type: "destination", destinationId: team.id }], waitMinutes: 5 }], + }), + ) + yield* alerts.createRule( + orgId, + userId, + adminRoles, + ruleRequest([tier("critical", 5, [team.id], chain.id)]), + ) + const silence = yield* silenceCheckout(orgId, userId) + yield* alerts.runSchedulerTick() + yield* ticks(alerts, 3) + yield* silence.end + // Off the minute: the held step fell due before the catch-up page went out. + yield* TestClock.adjust(Duration.seconds(90)) + yield* alerts.runSchedulerTick() + assert.deepStrictEqual(requests, ["trigger team"]) + }).pipe(Effect.provide(layerFor(testDb, state, requests))) + }) + it.effect("restarts on the critical tier's own chain when a warning incident escalates", () => { const testDb = createTestDb(trackedDbs) const state = { tracesAggregateRows: errorRateRows(10) } diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index 21c1c9c028..d3084801f1 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -150,6 +150,7 @@ import { applyEscalationPlan, enabledDestinationIds, type EscalationWorkRow, + alignDueEscalationStep, endEscalationRunsAbove, loadEscalationChain, planEscalationWork, @@ -3708,6 +3709,13 @@ export class AlertsService extends Context.Service 0 && catchUpDeliveries.length > 0) yield* insertIncidentDeliveries(db, catchUpDeliveries) + // The chain's due step goes out keyed to the catch-up, so a destination in both is paged once. + if (caughtUp.length > 0) + yield* alignDueEscalationStep(db, { + orgId: row.orgId, + incidentId: openIncident.id, + atMs: timestamp, + }) const result = { updated, isCatchUpSent: caughtUp.length > 0 } if (updated.length === 0) return result // Back down a tier, the higher tier's chain stops paging its on-call. @@ -5809,6 +5817,41 @@ export class AlertsService extends Context.Service + db.run( + selectStoredAlertRules() + .where(($) => [$.orgId.eq(row.orgId), $.id.eq(row.ruleId)]) + .limit(1), + ), + ) + const ruleRow = ruleRows[0] + if (ruleRow === undefined) return null + const rule = yield* normalizeRuleRow(ruleRow).pipe( + Effect.asSome, + Effect.catchTag("@maple/http/errors/AlertRuleStoredConfigInvalidError", (error) => + Effect.logWarning( + "Escalation silence check skipped: the rule's config is invalid", + ).pipe( + Effect.annotateLogs({ + ruleId: row.ruleId, + runId: row.id, + error: error.message, + }), + Effect.as(Option.none()), + ), + ), + ) + if (Option.isNone(rule)) return null + return yield* findActiveSilence(row.orgId, rule.value, row.groupKey, nowMs) + }, + ) + /** One run: plan from the row, then a conditional write that only one tick can win. */ const advanceEscalationRun = Effect.fn("AlertsService.advanceEscalationRun")(function* ( row: EscalationWorkRow, @@ -5820,7 +5863,15 @@ export class AlertsService extends Context.Service + db + .run( + PG.update(AlertEscalationRuns) + .set({ nextStepAt: input.atMs, updatedAt: input.atMs }) + .where(($) => [ + $.orgId.eq(input.orgId), + $.incidentId.eq(input.incidentId), + $.status.eq("active"), + $.nextStepAt.lte(input.atMs + ESCALATION_SILENCE_RECHECK_MS), + ]), + ) + .pipe(Effect.asVoid) + /** * Live runs with something to decide: an active run that is due or whose incident closed, and a * paused run whose ack is gone or whose incident closed. A run paused on a standing ack is not From 2cd8b77949564bdbbc91ec01d631de0688eba236 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 19:28:43 +0100 Subject: [PATCH 78/98] fix(alerts): a held step skips a destination the silence's renotify catch-up already paged Co-Authored-By: Claude Opus 5.5 --- .../alerts/AlertsService.escalation.test.ts | 47 +++++++++++++++++-- .../services/alerts/alert-escalation-runs.ts | 31 +++++++++++- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/services/alerts/AlertsService.escalation.test.ts b/packages/backend/src/services/alerts/AlertsService.escalation.test.ts index c1f96df927..79438f24b0 100644 --- a/packages/backend/src/services/alerts/AlertsService.escalation.test.ts +++ b/packages/backend/src/services/alerts/AlertsService.escalation.test.ts @@ -178,8 +178,8 @@ const setAck = (testDb: TestDb, orgId: OrgId, acknowledged: boolean) => ), ) -/** Silences the `checkout` service for an hour; ended early with `silences.end`. */ -const silenceCheckout = (orgId: OrgId, userId: UserId) => +/** Silences the `checkout` service, for an hour by default; ended early with `silences.end`. */ +const silenceCheckout = (orgId: OrgId, userId: UserId, minutes = 60) => Effect.gen(function* () { const silences = yield* AlertSilencesService const nowMs = yield* Clock.currentTimeMillis @@ -190,7 +190,7 @@ const silenceCheckout = (orgId: OrgId, userId: UserId) => new AlertSilenceCreateRequest({ reason: "checkout deploy", matchers: { services: ["checkout"] }, - endsAt: decodeIso(new Date(nowMs + 60 * 60_000).toISOString()), + endsAt: decodeIso(new Date(nowMs + minutes * 60_000).toISOString()), }), ) return { end: silences.end(orgId, userId, adminRoles, silence.id) } @@ -558,6 +558,47 @@ describe("escalation chains", () => { }).pipe(Effect.provide(layerFor(testDb, state, requests))) }) + it.effect("does not page a held step's destination again when the catch-up is a renotify", () => { + const testDb = createTestDb(trackedDbs) + const state = { tracesAggregateRows: errorRateRows(10) } + const requests: Array = [] + return Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const alerts = yield* AlertsService + const chains = yield* EscalationChainsService + const orgId = asOrgId("org_esc_silenced_renotify") + const userId = asUserId("user_esc_silenced_renotify") + const team = yield* webhook(alerts, orgId, userId, "team") + const step = { + targets: [{ type: "destination" as const, destinationId: team.id }], + waitMinutes: 5, + } + const chain = yield* chains.create( + orgId, + userId, + adminRoles, + new EscalationChainCreateRequest({ name: "Team twice", steps: [step, step] }), + ) + yield* alerts.createRule( + orgId, + userId, + adminRoles, + ruleRequest([tier("critical", 5, [team.id], chain.id)]), + ) + yield* alerts.runSchedulerTick() + yield* tick(alerts) + assert.deepStrictEqual(requests, ["trigger team"]) + const silence = yield* silenceCheckout(orgId, userId, 120) + // Step 2 falls due at minute 5 and the hourly reminder at 60; the silence holds both. + yield* ticks(alerts, 62) + assert.deepStrictEqual(requests, ["trigger team"]) + yield* silence.end + yield* tick(alerts) + yield* ticks(alerts, 3) + assert.deepStrictEqual(requests, ["trigger team", "renotify team"]) + }).pipe(Effect.provide(layerFor(testDb, state, requests))) + }) + it.effect("restarts on the critical tier's own chain when a warning incident escalates", () => { const testDb = createTestDb(trackedDbs) const state = { tracesAggregateRows: errorRateRows(10) } diff --git a/packages/backend/src/services/alerts/alert-escalation-runs.ts b/packages/backend/src/services/alerts/alert-escalation-runs.ts index 41950823f0..0e645cadf6 100644 --- a/packages/backend/src/services/alerts/alert-escalation-runs.ts +++ b/packages/backend/src/services/alerts/alert-escalation-runs.ts @@ -359,13 +359,40 @@ export const applyEscalationPlan = (db: MapleDb, input: ApplyEscalationPlanInput passCount: row.repeatCount + 1, }, } - const destinationIds = Arr.dedupe( + const stepDestinationIds = Arr.dedupe( Arr.filter( Arr.map(plan.step.targets, (target) => target.destinationId), (id) => input.enabled.has(id), ), ) - if (destinationIds.length === 0) { + // A silence's catch-up at this step's due time may have gone out as a renotify, which the + // step's trigger key would not dedupe against; those destinations are already paged. + const renotified = + stepDestinationIds.length === 0 + ? [] + : yield* db.run( + PG.from(AlertDeliveryEvents) + .select("destinationId") + .where(($) => [ + $.orgId.eq(row.orgId), + $.incidentId.eq(row.incidentId), + $.deliveryKey.in_( + ...Arr.map(stepDestinationIds, (destinationId) => + makeAlertDeliveryKey( + row.incidentId, + destinationId, + "renotify", + row.nextStepAt, + ), + ), + ), + ]), + ) + const destinationIds = Arr.filter( + stepDestinationIds, + (id) => !Arr.some(renotified, (delivery) => delivery.destinationId === id), + ) + if (stepDestinationIds.length === 0) { yield* Effect.logWarning( "Escalation step has no enabled destination; nobody was notified", ).pipe( From 3b0ba95e99c16fa018fca36a7b893642a6ce7029 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 19:39:25 +0100 Subject: [PATCH 79/98] fix(web): schedule form offers UTC, keeps the shown start across a zone change, refuses an empty window; override form says members failed to load --- .../web/src/page/alerts/schedule-form/form.ts | 4 +- .../src/page/alerts/schedule-form/model.ts | 9 ++++- .../schedule-form/schedule-form.scene.test.ts | 40 +++++++++++++++++++ .../src/page/alerts/schedule-form/update.ts | 14 +++++-- .../web/src/page/alerts/schedule-form/view.ts | 4 +- apps/web/src/page/alerts/schedule/model.ts | 2 + .../alerts/schedule/schedule.scene.test.ts | 16 +++++++- apps/web/src/page/alerts/schedule/update.ts | 5 ++- apps/web/src/page/alerts/schedule/view.ts | 8 +++- 9 files changed, 91 insertions(+), 11 deletions(-) diff --git a/apps/web/src/page/alerts/schedule-form/form.ts b/apps/web/src/page/alerts/schedule-form/form.ts index d08691f3a6..c9a759d8b9 100644 --- a/apps/web/src/page/alerts/schedule-form/form.ts +++ b/apps/web/src/page/alerts/schedule-form/form.ts @@ -203,7 +203,9 @@ const restrictionError = (restriction: RestrictionDraft): Option.Option ? Option.some("Pick at least one day") : !isLocalTime(restriction.startTime) || !isLocalTime(restriction.endTime) ? Option.some("Use HH:MM times") - : Option.none() + : restriction.endTime === restriction.startTime + ? Option.some("From and To can't be the same time") + : Option.none() /** The layer's start: the saved instant while untouched, else its text on the zone's clock. */ const layerStartMs = (layer: LayerDraft, timeZone: string): Option.Option => diff --git a/apps/web/src/page/alerts/schedule-form/model.ts b/apps/web/src/page/alerts/schedule-form/model.ts index f7b5e06669..da4206ac16 100644 --- a/apps/web/src/page/alerts/schedule-form/model.ts +++ b/apps/web/src/page/alerts/schedule-form/model.ts @@ -1,7 +1,7 @@ import * as Policy from "@maple/domain/policy" import { OnCallScheduleId } from "@maple/domain/http" import { V2OnCallSchedule } from "@maple/domain/http/v2" -import { Schema } from "effect" +import { Array as Arr, Order, Schema, String as Str } from "effect" import { KeyedSchema } from "@maple/views/data/keyed" import { LoadFailure } from "@maple/views/data/load-failure" import * as Combobox from "@maple/views/ui/combobox" @@ -38,5 +38,12 @@ export const Model = Schema.Struct({ }) export type Model = typeof Model.Type +/** The picker's zones: the browser's list, plus `UTC` (which it leaves out) and the form's own zone. */ +export const zoneItems = (model: Pick): ReadonlyArray => + Arr.sort( + Arr.dedupe(Arr.filter([...model.zones, "UTC", model.form.timeZone], Str.isNonEmpty)), + Order.String, + ) + /** The viewer may change schedules (`alerts:manage`); the API refuses anyone else. */ export const canManage = (model: Pick): boolean => Policy.has(model.grants, "alerts:manage") diff --git a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts index 07cac6c86b..f086b7ddc2 100644 --- a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts +++ b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts @@ -214,6 +214,46 @@ describe("schedule form", () => { ) }) + test("UTC can be picked although the browser's zone list leaves it out", () => { + const model = run(ready(), [ + Message.ReadDefaults({ + timeZone: "Europe/Berlin", + nowMs: ONCALL_NOW, + zones: ["Europe/Berlin", "America/New_York"], + }), + Message.GotZoneMessage({ message: { _tag: "ClickedItem", value: "UTC" } }), + ]) + expect(model.form.timeZone).toBe("UTC") + }) + + test("a new zone keeps each layer's start on the wall clock shown, and saves it in that zone", () => { + const base = ready() + const loaded = run({ ...base, editingId: Schema.decodeSync(OnCallScheduleId)(scheduleUuid(1)) }, []) + const filled = { ...loaded, form: formFromSchedule(checkoutSchedule(ONCALL_NOW)) } + const shown = filled.form.layers[0]?.startsAt ?? "" + const moved = run(filled, [ + Message.GotZoneMessage({ message: { _tag: "ClickedItem", value: "America/New_York" } }), + ]) + expect(moved.form.layers[0]?.startsAt).toBe(shown) + expect(Option.map(payloadOf(moved.form), (payload) => payload.layers[0].starts_at)).toEqual( + Option.map(localInputMs(shown, "America/New_York"), (ms) => new Date(ms).toISOString()), + ) + }) + + test("a window whose From and To are the same time is refused", () => { + const model = run(ready(), [ + Message.ClickedAddRestriction({ key: "l0" }), + Message.ChangedRestrictionTime({ key: "l0", restrictionKey: "r1", edge: "end", value: "18:00" }), + Message.ClickedSave(), + ]) + expect(payloadOf(model.form)).toEqual(Option.none()) + scene( + { update: pageUpdate, view }, + given(model), + expectView(text("From and To can't be the same time")).toExist(), + ) + }) + test("a start before year 1000 keeps four year digits", () => { expect(localInputOf(Date.UTC(999, 0, 2, 9, 0), "UTC")).toBe("0999-01-02T09:00") }) diff --git a/apps/web/src/page/alerts/schedule-form/update.ts b/apps/web/src/page/alerts/schedule-form/update.ts index 8b43f9ba79..f8ee3957de 100644 --- a/apps/web/src/page/alerts/schedule-form/update.ts +++ b/apps/web/src/page/alerts/schedule-form/update.ts @@ -28,7 +28,7 @@ import { type RestrictionDraft, } from "./form" import { type Direction, Message } from "./message" -import { canManage, type Model } from "./model" +import { canManage, type Model, zoneItems } from "./model" type Return = PageReturn type FormRoute = Extract @@ -149,14 +149,22 @@ const zonePicked = (model: Model, message: Combobox.Message): Return => { Message.GotZoneMessage({ message: child }), ) const zone = - step.outMessage?._tag === "ChangedValue" && model.zones.includes(step.outMessage.value) + step.outMessage?._tag === "ChangedValue" && zoneItems(model).includes(step.outMessage.value) ? step.outMessage.value : null return { model: { ...model, zonePicker: step.model, - form: zone === null ? model.form : { ...model.form, timeZone: zone }, + // Each layer keeps its wall-clock start, now read in the new zone, so what is shown is what saves. + form: + zone === null || zone === model.form.timeZone + ? model.form + : { + ...model.form, + timeZone: zone, + layers: Arr.map(model.form.layers, (layer) => ({ ...layer, startsAtMs: null })), + }, }, commands, } diff --git a/apps/web/src/page/alerts/schedule-form/view.ts b/apps/web/src/page/alerts/schedule-form/view.ts index 630aed9798..a122e98917 100644 --- a/apps/web/src/page/alerts/schedule-form/view.ts +++ b/apps/web/src/page/alerts/schedule-form/view.ts @@ -48,7 +48,7 @@ import { zoneError, } from "./form" import { type LayerField, Message } from "./message" -import { canManage, type Model } from "./model" +import { canManage, type Model, zoneItems } from "./model" import { participantMenuId } from "./update" // `/alerts/schedules/new`: the schedule's name and zone, then its layers: who rotates, how they @@ -142,7 +142,7 @@ const zoneSection = (h: H, model: Model): Html => view: ComboboxView.view, toParentMessage: (message) => Message.GotZoneMessage({ message }), viewInputs: { - items: model.zones, + items: zoneItems(model), ariaLabel: "Time zone", placeholder: "Search time zones…", emptyText: "No time zone matches that", diff --git a/apps/web/src/page/alerts/schedule/model.ts b/apps/web/src/page/alerts/schedule/model.ts index 8ca823d278..e20a3b36c0 100644 --- a/apps/web/src/page/alerts/schedule/model.ts +++ b/apps/web/src/page/alerts/schedule/model.ts @@ -32,6 +32,8 @@ export const Model = Schema.Struct({ overrides: KeyedSchema(Schema.Array(V2OnCallOverride), LoadFailure), /** `null` until Clerk answers, and after a failed read: names then read neutrally. */ members: Schema.NullOr(Schema.Array(OrgMember)), + /** The members read failed: the override form says so instead of offering nobody. */ + isMembersFailed: Schema.Boolean, grants: Schema.Array(Policy.PermissionName), overrideDialog: Dialog.Model, overrideDraft: OverrideDraft, diff --git a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts index 3665ccb3a1..d187433e63 100644 --- a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts +++ b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts @@ -7,7 +7,9 @@ import { AsyncData } from "foldkit" import { isoMs, msIso } from "../shared/time" import { memberName } from "../shared/org-members" import { formatShiftEdge } from "../shared/oncall-schedules" -import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { expect as expectView, given, Mount, role, scene, text, withViewInputs } from "foldkit/scene" +import * as Dialog from "@maple/views/ui/dialog" +import { MountDialog } from "@maple/views/ui/dialog" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" import { @@ -365,4 +367,16 @@ describe("on-call schedule page", () => { ), ) }) + test("the override form says the members did not load, rather than asking to pick nobody", () => { + const model = run(loaded(ONCALL_NOW), [ + Message.CompletedFetchMembers({ result: Result.fail("Clerk is down") }), + Message.ClickedNewOverride(), + ]) + scene( + { update: pageUpdate, view }, + given(model), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Couldn't load members, try reloading the page")).toExist(), + ) + }) }) diff --git a/apps/web/src/page/alerts/schedule/update.ts b/apps/web/src/page/alerts/schedule/update.ts index 5a5ab4bc86..cd24f0c2f9 100644 --- a/apps/web/src/page/alerts/schedule/update.ts +++ b/apps/web/src/page/alerts/schedule/update.ts @@ -75,6 +75,7 @@ export const init = (route: ScheduleRoute, context: PageContext): Return => { shifts: Keyed.idle(), overrides: Keyed.idle(), members: null, + isMembersFailed: false, grants: [], overrideDialog: Dialog.init(OVERRIDE_DIALOG_ID, "dialog"), overrideDraft: { userId: "", startsAt: "", endsAt: "", startMs: null, endMs: null }, @@ -320,9 +321,9 @@ export const update = (model: Model, message: Message, context: PageContext): Re }), CompletedFetchMembers: ({ result }) => Result.match(result, { - onSuccess: (members) => ({ model: { ...model, members } }), + onSuccess: (members) => ({ model: { ...model, members, isMembersFailed: false } }), // A failed read keeps names neutral ("Member") rather than calling everyone a former member. - onFailure: () => ({ model }), + onFailure: () => ({ model: { ...model, isMembersFailed: true } }), }), CompletedFetchSession: ({ orgId, grants }) => orgId === context.orgId ? { model: { ...model, grants } } : { model }, diff --git a/apps/web/src/page/alerts/schedule/view.ts b/apps/web/src/page/alerts/schedule/view.ts index 51f81d6e59..a235f969c8 100644 --- a/apps/web/src/page/alerts/schedule/view.ts +++ b/apps/web/src/page/alerts/schedule/view.ts @@ -283,6 +283,7 @@ const overrideDialog = (h: H, model: Model, schedule: V2OnCallSchedule): Html => { defaultValue: model.overrideDraft.userId, className: "w-full", + isDisabled: model.isMembersFailed, attributes: [ h.Key(`${id}-person-${model.overrideOpenCount}-${(model.members ?? []).length}`), h.Id(`${id}-person`), @@ -290,7 +291,12 @@ const overrideDialog = (h: H, model: Model, schedule: V2OnCallSchedule): Html => ], }, [ - nativeOption("", "Pick a person"), + nativeOption( + "", + model.isMembersFailed + ? "Couldn't load members, try reloading the page" + : "Pick a person", + ), ...Arr.map(model.members ?? [], (member) => nativeOption(member.userId, member.name)), ], ), From ba9b5faecc53a542b2a3c6c5faa408b161d2e58a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 19:45:34 +0100 Subject: [PATCH 80/98] fix(web): a window with equal From and To shows only its error, no midnight note --- .../alerts/schedule-form/schedule-form.scene.test.ts | 2 ++ apps/web/src/page/alerts/schedule-form/view.ts | 10 +++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts index f086b7ddc2..5a498d4899 100644 --- a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts +++ b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts @@ -251,6 +251,8 @@ describe("schedule form", () => { { update: pageUpdate, view }, given(model), expectView(text("From and To can't be the same time")).toExist(), + expectView(text("Ends the next morning")).not.toExist(), + expectView(text("Same day")).not.toExist(), ) }) diff --git a/apps/web/src/page/alerts/schedule-form/view.ts b/apps/web/src/page/alerts/schedule-form/view.ts index a122e98917..d3f768f25e 100644 --- a/apps/web/src/page/alerts/schedule-form/view.ts +++ b/apps/web/src/page/alerts/schedule-form/view.ts @@ -302,12 +302,16 @@ const rotationField = (h: H, layer: LayerDraft): Html => { ]) } -/** One weekly window: its days, from and to (an end at or before the start crosses midnight). */ +/** One weekly window: its days, from and to (an end before the start crosses midnight). */ const restrictionRow = (h: H, layer: LayerDraft, restriction: RestrictionDraft): Html => { const key = layer.key const restrictionKey = restriction.key - const isComplete = isLocalTime(restriction.startTime) && isLocalTime(restriction.endTime) - const crossesMidnight = isComplete && restriction.endTime <= restriction.startTime + // Equal times are refused with a field error, so they get no note either. + const isComplete = + isLocalTime(restriction.startTime) && + isLocalTime(restriction.endTime) && + restriction.endTime !== restriction.startTime + const crossesMidnight = isComplete && restriction.endTime < restriction.startTime return vstack(h, { gap: "2", className: "rounded-md border p-3", attributes: [h.Key(restrictionKey)] }, [ hstack(h, { gap: "2", justify: "between", wrap: true }, [ multiSegmentedSelect(h, { From af53c7c96b38488b990a2ee7c3176e1342301b3c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 19:53:09 +0100 Subject: [PATCH 81/98] fix(web): a lane with no shift in range says so; move buttons name their layer --- .../schedule-form/schedule-form.scene.test.ts | 5 ++++ .../web/src/page/alerts/schedule-form/view.ts | 1 + apps/web/src/page/kit/pick-list.ts | 10 +++++--- .../views/src/ui/shift-timeline.scene.test.ts | 24 +++++++++++++++++++ packages/views/src/ui/shift-timeline.ts | 6 ++++- 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts index 5a498d4899..26334f22a1 100644 --- a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts +++ b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts @@ -122,6 +122,11 @@ describe("schedule form", () => { }), ]) expect(model.form.layers[0]?.participants).toEqual([memberId(0), memberId(2), memberId(1)]) + scene( + { update: pageUpdate, view }, + given(model), + expectView(role("button", { name: `Move ${oncallMembers[2]?.name} up in layer 1` })).toExist(), + ) expect(names(pageUpdate(model, Message.ClickedSave()).commands)).toEqual(["SaveOnCallSchedule"]) }) diff --git a/apps/web/src/page/alerts/schedule-form/view.ts b/apps/web/src/page/alerts/schedule-form/view.ts index d3f768f25e..9a45e5a439 100644 --- a/apps/web/src/page/alerts/schedule-form/view.ts +++ b/apps/web/src/page/alerts/schedule-form/view.ts @@ -195,6 +195,7 @@ const participantsField = (h: H, model: Model, layer: LayerDraft, index: number) removeLabel: `Remove ${name} from layer ${index + 1}`, onRemove: Message.ClickedRemoveParticipant({ key, userId }), move: { + context: `layer ${index + 1}`, up: at === 0 ? null diff --git a/apps/web/src/page/kit/pick-list.ts b/apps/web/src/page/kit/pick-list.ts index 07124864a9..4e1dc8c9a3 100644 --- a/apps/web/src/page/kit/pick-list.ts +++ b/apps/web/src/page/kit/pick-list.ts @@ -12,6 +12,9 @@ import { typography } from "@maple/views/ui/typography" // `PickList`: what a form has picked (destinations, people, schedules), each removable and, in an // ordered list, movable, then an "Add" menu of the rest. +const moveLabel = (label: string, direction: "up" | "down", context: string | undefined): string => + context === undefined ? `Move ${label} ${direction}` : `Move ${label} ${direction} in ${context}` + /** One picked entry: its media (an icon or an avatar), its name and how to remove or move it. */ export type PickedEntry = Readonly<{ key: string @@ -22,7 +25,8 @@ export type PickedEntry = Readonly<{ removeLabel: string onRemove: M /** Present on an ordered list: the up and down buttons, disabled at its ends. */ - move?: Readonly<{ up: M | null; down: M | null }> + /** `context` names the list in the move buttons ("layer 2"), when one page shows several lists. */ + move?: Readonly<{ up: M | null; down: M | null; context?: string }> }> /** @@ -61,7 +65,7 @@ export const pickList = ( iconButton( h, { - label: `Move ${entry.label} up`, + label: moveLabel(entry.label, "up", entry.move.context), isDisabled: isDisabled || entry.move.up === null, className: "text-muted-foreground", attributes: entry.move.up === null ? [] : [h.OnClick(entry.move.up)], @@ -71,7 +75,7 @@ export const pickList = ( iconButton( h, { - label: `Move ${entry.label} down`, + label: moveLabel(entry.label, "down", entry.move.context), isDisabled: isDisabled || entry.move.down === null, className: "text-muted-foreground", attributes: entry.move.down === null ? [] : [h.OnClick(entry.move.down)], diff --git a/packages/views/src/ui/shift-timeline.scene.test.ts b/packages/views/src/ui/shift-timeline.scene.test.ts index f9cdc0fafc..0fa862edbf 100644 --- a/packages/views/src/ui/shift-timeline.scene.test.ts +++ b/packages/views/src/ui/shift-timeline.scene.test.ts @@ -49,6 +49,21 @@ const view = (model: Model, h: HtmlBuilder): Html => }, ], }, + { + key: "later", + label: "Later layer", + emptyText: "Not active in this range", + bars: [ + { + key: "c", + startMs: FROM + 10 * 24 * HOUR, + endMs: FROM + 11 * 24 * HOUR, + label: "Cy", + title: "Cy, next week", + tone: "neutral", + }, + ], + }, ], }) const update = (model: Model) => ({ model, commands: [] }) @@ -73,4 +88,13 @@ describe("shift timeline", () => { expectView(text("Now, Tue 10 Nov, 11:30")).not.toExist(), ) }) + + test("a lane whose shifts all fall outside the range says so instead of drawing blank", () => { + scene( + { update, view }, + given({ nowMs: FROM + 35 * HOUR }), + expectView(text("Not active in this range")).toExist(), + expectView(role("img", { name: "Cy, next week" })).not.toExist(), + ) + }) }) diff --git a/packages/views/src/ui/shift-timeline.ts b/packages/views/src/ui/shift-timeline.ts index f46f006200..9ed016feef 100644 --- a/packages/views/src/ui/shift-timeline.ts +++ b/packages/views/src/ui/shift-timeline.ts @@ -270,7 +270,11 @@ export const shiftTimeline = ( ), h.div( [h.Class("relative h-8 grow"), h.Role("cell")], - row.bars.length === 0 && row.emptyText !== undefined + // Bars wholly outside the range draw nothing, so they count as none. + row.emptyText !== undefined && + !Arr.some(row.bars, (bar) => + Option.isSome(placeBar(fromMs, toMs, bar.startMs, bar.endMs)), + ) ? [ typography( h, From 26e332de84085af8d38bc9ea90a3b8a56bf0c578 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 20:13:38 +0100 Subject: [PATCH 82/98] fix(web): a failed chain read keeps the tier's chain instead of calling it unavailable --- apps/web/src/page/alerts/editor/command.ts | 4 ++-- .../alerts/editor/editor-chains.scene.test.ts | 15 +++++++++++++++ apps/web/src/page/alerts/editor/init.ts | 1 + apps/web/src/page/alerts/editor/message.ts | 2 +- apps/web/src/page/alerts/editor/model.ts | 4 +++- apps/web/src/page/alerts/editor/update.ts | 4 +++- .../src/page/alerts/editor/view-notifications.ts | 16 ++++++++++++---- 7 files changed, 37 insertions(+), 9 deletions(-) diff --git a/apps/web/src/page/alerts/editor/command.ts b/apps/web/src/page/alerts/editor/command.ts index 2e26a8c597..7fdc661fc5 100644 --- a/apps/web/src/page/alerts/editor/command.ts +++ b/apps/web/src/page/alerts/editor/command.ts @@ -51,12 +51,12 @@ export const FetchEditorConnectors = Command.define("FetchAlertEditorConnectors" }).pipe(Effect.orElseSucceed(() => Message.CompletedFetchConnectors({ connectors: [] }))), }) -/** The org's escalation chains for the tier pickers; a failed read offers none, as the connectors do. */ +/** The org's escalation chains for the tier pickers; `null` when the read failed. */ export const FetchEditorChains = Command.define("FetchAlertEditorChains", { messages: [Message.CompletedFetchEscalationChains], execute: listEscalationChains.pipe( Effect.map((chains) => Message.CompletedFetchEscalationChains({ chains })), - Effect.orElseSucceed(() => Message.CompletedFetchEscalationChains({ chains: [] })), + Effect.orElseSucceed(() => Message.CompletedFetchEscalationChains({ chains: null })), ), }) diff --git a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts index 8a5d99ce5c..e0267892dc 100644 --- a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts @@ -104,6 +104,21 @@ describe("alert rule editor, escalation chains", () => { expect(run(model, [pickChain("warning", "none")]).form?.escalationChainId).toBeNull() }) + test("a failed chain read keeps the tier's chain and says the list did not load, not that the chain is gone", () => { + const model = run(ready(), [ + pickChain("warning", chainUuid(1)), + Message.CompletedFetchEscalationChains({ chains: null }), + ]) + expect(model.form?.escalationChainId).toBe(chainUuid(1)) + scene( + { update: pageUpdate, view }, + given(model), + themeResolved(), + expectView(text("Couldn't load escalation chains; reload the page to change this one")).toExist(), + expectView(text("Unavailable chain")).not.toExist(), + ) + }) + test("the critical tier needs a destination or a chain of its own", () => { const tiered = run(ready(), [Message.ClickedAddCriticalTier()]) expect(issuesOf(tiered)).toContain("Critical destinations") diff --git a/apps/web/src/page/alerts/editor/init.ts b/apps/web/src/page/alerts/editor/init.ts index de8a1ad7c1..9e6f191c9d 100644 --- a/apps/web/src/page/alerts/editor/init.ts +++ b/apps/web/src/page/alerts/editor/init.ts @@ -47,6 +47,7 @@ export const init = (route: CreateRoute, _context: PageContext): PageReturn readClock(model, context, nowMs), CompletedReadGrants: ({ grants }) => ({ model: { ...model, grants } }), CompletedFetchConnectors: ({ connectors }) => connectorsAnswered(model, connectors), - CompletedFetchEscalationChains: ({ chains }) => ({ model: { ...model, escalationChains: chains } }), + CompletedFetchEscalationChains: ({ chains }) => ({ + model: { ...model, escalationChains: chains, isEscalationChainsFailed: chains === null }, + }), GotChatConnectMessage: ({ message: inner }) => chatConnectStep(model, inner), CompletedFetchTraceFacets: ({ key, result }) => ({ model: { ...model, traceFacets: Keyed.accept(model.traceFacets, key, result) }, diff --git a/apps/web/src/page/alerts/editor/view-notifications.ts b/apps/web/src/page/alerts/editor/view-notifications.ts index 281c512c3c..105537337c 100644 --- a/apps/web/src/page/alerts/editor/view-notifications.ts +++ b/apps/web/src/page/alerts/editor/view-notifications.ts @@ -40,6 +40,7 @@ type NotificationsView = NotifyView & | "isMessageTemplateOpen" | "mountedForm" | "escalationChains" + | "isEscalationChainsFailed" | "selects" > type NotificationsForm = Pick< @@ -121,13 +122,19 @@ const chainPicker = ( ]), ], onNone: () => - chainId === null && destinationIds.length === 0 + model.isEscalationChainsFailed && chainId !== null ? [ - typography(h, { size: "xs", tone: "destructive", as: "p" }, [ - "Pick a destination or an escalation chain", + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + "Couldn't load escalation chains; reload the page to change this one", ]), ] - : [], + : chainId === null && destinationIds.length === 0 + ? [ + typography(h, { size: "xs", tone: "destructive", as: "p" }, [ + "Pick a destination or an escalation chain", + ]), + ] + : [], }) return formSelect(h, model.selects[id], id, value, chainOptions(model, chainId), { className: "h-9 w-full", @@ -378,6 +385,7 @@ const viewOf = stableSlice((model: Model) => "chatConnectMenuId", "brokenLogos", "escalationChains", + "isEscalationChainsFailed", "selects", ]), ) From 5bf58288e8350e1f9c31bdffda828334d3baf916 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 20:22:35 +0100 Subject: [PATCH 83/98] fix(web): the rule editor rereads chains on returning to the tab; chain step pickers name their step --- apps/web/src/data/poll.ts | 23 +++++++++++++++---- .../alerts/editor/editor-chains.scene.test.ts | 6 +++++ apps/web/src/page/alerts/editor/index.ts | 9 ++++++++ apps/web/src/page/alerts/editor/message.ts | 2 ++ apps/web/src/page/alerts/editor/update.ts | 3 ++- .../escalation-dialog/dialog.scene.test.ts | 1 + .../src/page/alerts/escalation-dialog/view.ts | 1 + apps/web/src/page/kit/destination-picker.ts | 15 ++++++++++-- 8 files changed, 53 insertions(+), 7 deletions(-) diff --git a/apps/web/src/data/poll.ts b/apps/web/src/data/poll.ts index 560ca8b7a0..981bccaf99 100644 --- a/apps/web/src/data/poll.ts +++ b/apps/web/src/data/poll.ts @@ -8,7 +8,10 @@ export interface Presence { } /** A target's events, or none off the browser (tests, SSR), where `document` and `window` do not exist. */ -const changesOf = (target: () => EventTarget | undefined, type: "visibilitychange" | "online"): Stream.Stream => +const changesOf = ( + target: () => EventTarget | undefined, + type: "visibilitychange" | "online", +): Stream.Stream => Stream.unwrap( Effect.sync(() => { const resolved = target() @@ -36,6 +39,10 @@ export const whenPresent = (presence: Presence = browserPresence): Stream.Stream Stream.take(1), ) +/** Each return to the tab (shown again, or back online), e.g. to reread what another tab changed. */ +export const returns = (presence: Presence = browserPresence): Stream.Stream => + Stream.filter(presence.changes, () => presence.isPresent()) + type Signal = "Tick" | "Change" type PollState = Readonly<{ isMissed: boolean; caughtUpAtMs: number | null }> @@ -46,9 +53,16 @@ type PollState = Readonly<{ isMissed: boolean; caughtUpAtMs: number | null }> * once, and a regular tick within half an interval of that catch-up is dropped, so the next * fetch comes 0.5x to 1.5x the interval later. */ -export const pollTicks = (every: Duration.Input, presence: Presence = browserPresence): Stream.Stream => { +export const pollTicks = ( + every: Duration.Input, + presence: Presence = browserPresence, +): Stream.Stream => { const everyMs = Duration.toMillis(every) - const next = (state: PollState, signal: Signal, nowMs: number): readonly [PollState, ReadonlyArray] => { + const next = ( + state: PollState, + signal: Signal, + nowMs: number, + ): readonly [PollState, ReadonlyArray] => { if (!presence.isPresent()) return [{ ...state, isMissed: state.isMissed || signal === "Tick" }, []] if (signal === "Change") return state.isMissed ? [{ isMissed: false, caughtUpAtMs: nowMs }, [undefined]] : [state, []] @@ -75,4 +89,5 @@ export const backoffPollTicks = ( fastTicks: number, slow: Duration.Input, presence: Presence = browserPresence, -): Stream.Stream => Stream.concat(Stream.take(pollTicks(fast, presence), fastTicks), pollTicks(slow, presence)) +): Stream.Stream => + Stream.concat(Stream.take(pollTicks(fast, presence), fastTicks), pollTicks(slow, presence)) diff --git a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts index e0267892dc..4e507b2311 100644 --- a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts @@ -119,6 +119,12 @@ describe("alert rule editor, escalation chains", () => { ) }) + test("returning to the tab reads the chains again, so one made in the Create a chain tab can be picked", () => { + expect(names(pageUpdate(ready(), Message.ReturnedToTab()).commands)).toEqual([ + "FetchAlertEditorChains", + ]) + }) + test("the critical tier needs a destination or a chain of its own", () => { const tiered = run(ready(), [Message.ClickedAddCriticalTier()]) expect(issuesOf(tiered)).toContain("Critical destinations") diff --git a/apps/web/src/page/alerts/editor/index.ts b/apps/web/src/page/alerts/editor/index.ts index 2b70f3a3bd..3f5d73364c 100644 --- a/apps/web/src/page/alerts/editor/index.ts +++ b/apps/web/src/page/alerts/editor/index.ts @@ -1,5 +1,6 @@ import { Option, Schema, Stream } from "effect" import { Subscription } from "foldkit" +import { returns } from "../../../data/poll" import type { Resources } from "../../../platform/resources" import { definePage, type PageSubscriptionInput } from "../../contract" import { documentStream } from "../../dashboards/kit/document-stream" @@ -83,6 +84,14 @@ const subscriptions = Subscription.make, Message, R ? collectionLoadFailed(`alert_rules:${orgId}`, () => Message.FailedLoadRules({ orgId })) : Stream.empty, }), + /** Chains are read again on each return to the tab, so one made from "Create a chain" shows up. */ + chainsRefresh: entry( + {}, + { + modelToDependencies: () => ({}), + dependenciesToStream: () => Stream.map(returns(), () => Message.ReturnedToTab()), + }, + ), /** `useDashboardsRead`, only for the id-lookup fallback (a widget link without its snapshot). */ sourceDashboard: entry( { orgId: Schema.String, dashboardId: Schema.NullOr(Schema.String) }, diff --git a/apps/web/src/page/alerts/editor/message.ts b/apps/web/src/page/alerts/editor/message.ts index 5bd52ab4a6..4571e8145a 100644 --- a/apps/web/src/page/alerts/editor/message.ts +++ b/apps/web/src/page/alerts/editor/message.ts @@ -71,6 +71,8 @@ export const Message = defineMessageUnion({ CompletedReadClock: { nowMs: Schema.Number }, CompletedReadGrants: { grants: Schema.Array(Policy.PermissionName) }, CompletedFetchConnectors: { connectors: Schema.Array(V2ChatConnector) }, + /** Back on this tab: a chain made from "Create a chain" (a new tab) can now be picked. */ + ReturnedToTab: {}, CompletedFetchEscalationChains: { chains: Schema.NullOr(Schema.Array(V2EscalationChain)) }, CompletedFetchTraceFacets: { key: Schema.String, result: Schema.Result(TraceFacets, LoadFailure) }, CompletedFetchAutocomplete: { diff --git a/apps/web/src/page/alerts/editor/update.ts b/apps/web/src/page/alerts/editor/update.ts index a328b911ba..be452f6280 100644 --- a/apps/web/src/page/alerts/editor/update.ts +++ b/apps/web/src/page/alerts/editor/update.ts @@ -6,7 +6,7 @@ import { Command } from "foldkit" import type { PageContext } from "../../contract" import * as DestinationDialog from "../destination/dialog" import { completeSave, completeTest, runTest, save } from "./actions" -import { FocusSqlCaret, InsertSqlToken, ReloadPage } from "./command" +import { FetchEditorChains, FocusSqlCaret, InsertSqlToken, ReloadPage } from "./command" import { SQL_TEXTAREA_ID } from "./view-sql" import { chatConnectStep, connectorsAnswered, liftDialog, menuStep, removeDestination } from "./dest-steps" import { suggestedName } from "./derive" @@ -82,6 +82,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedReadClock: ({ nowMs }) => readClock(model, context, nowMs), CompletedReadGrants: ({ grants }) => ({ model: { ...model, grants } }), CompletedFetchConnectors: ({ connectors }) => connectorsAnswered(model, connectors), + ReturnedToTab: () => ({ model, commands: [FetchEditorChains()] }), CompletedFetchEscalationChains: ({ chains }) => ({ model: { ...model, escalationChains: chains, isEscalationChainsFailed: chains === null }, }), diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts index 34f99e0a65..dbdc4ebc83 100644 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts +++ b/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts @@ -46,6 +46,7 @@ describe("escalation chain dialog view", () => { expectView(role("textbox", { name: "Name" })).not.toBeDisabled(), expectView(role("spinbutton", { name: "Repeat the whole chain" })).not.toBeDisabled(), expectView(role("button", { name: "Add step" })).not.toBeDisabled(), + expectView(role("button", { name: /^Stop notifying .+ in step 1$/u })).toExist(), ) }) diff --git a/apps/web/src/page/alerts/escalation-dialog/view.ts b/apps/web/src/page/alerts/escalation-dialog/view.ts index b9e18f807d..30d6ecae8f 100644 --- a/apps/web/src/page/alerts/escalation-dialog/view.ts +++ b/apps/web/src/page/alerts/escalation-dialog/view.ts @@ -236,6 +236,7 @@ const stepBlock = (h: H, model: Model, inputs: ViewInputs, step: StepDraft, inde toMenuMessage: (message) => Message.GotStepMenuMessage({ key, message }), onRemove: (id) => Message.ClickedRemoveDestination({ key, id }), isDisabled: model.isSaving, + context: `step ${index + 1}`, emptyText: inputs.destinations !== null && inputs.destinations.length === 0 ? "No destinations exist yet. Add one in Routing first." diff --git a/apps/web/src/page/kit/destination-picker.ts b/apps/web/src/page/kit/destination-picker.ts index 8c732e5ec3..80238cd3ee 100644 --- a/apps/web/src/page/kit/destination-picker.ts +++ b/apps/web/src/page/kit/destination-picker.ts @@ -27,10 +27,13 @@ export const destinationPicker = ( emptyText: string /** The pick is shown but cannot change (a form whose save is in flight). */ isDisabled?: boolean + /** Names the list in its buttons ("step 2") when one form shows several pickers. */ + context?: string }>, ): Html => { const rows = options.rows ?? [] const isDisabled = options.isDisabled ?? false + const inContext = options.context === undefined ? "" : ` in ${options.context}` // `null` until destinations sync: a neutral name, not "unavailable", for the saved ones. const fallback = options.rows === null ? "Destination" : "Unavailable destination" const selected = Arr.map(options.selectedIds, (id) => ({ @@ -54,7 +57,7 @@ export const destinationPicker = ( iconButton( h, { - label: `Stop notifying ${destination.name}`, + label: `Stop notifying ${destination.name}${inContext}`, className: "text-muted-foreground", isDisabled, attributes: [h.OnClick(options.onRemove(destination.id))], @@ -95,7 +98,15 @@ export const destinationPicker = ( className: "flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-muted-foreground hover:bg-surface-sunken active:bg-surface-sunken hover:text-foreground active:text-foreground", isDisabled, - attributes: trigger, + attributes: + options.context === undefined + ? trigger + : [ + ...trigger, + h.AriaLabel( + `Add destination to ${options.context}`, + ), + ], }, [icon(h, "PlusIcon", { size: 14 }), "Add destination"], ), From 77eab5a660f2b406eb12d19b857f49f1b85c2a15 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 00:24:59 +0100 Subject: [PATCH 84/98] fix(web): a superseded run reads as a severity change; a failed chain read shows on every tier --- .../alerts/editor/editor-chains.scene.test.ts | 9 +++++++ .../page/alerts/editor/view-notifications.ts | 24 +++++++++++-------- .../page/alerts/shared/escalation-chains.ts | 2 +- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts index 4e507b2311..c6556ec977 100644 --- a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts @@ -119,6 +119,15 @@ describe("alert rule editor, escalation chains", () => { ) }) + test("a failed chain read says so on a tier with no chain, rather than looking like the org has none", () => { + scene( + { update: pageUpdate, view }, + given(run(ready(), [Message.CompletedFetchEscalationChains({ chains: null })])), + themeResolved(), + expectView(text("Couldn't load escalation chains; reload the page to pick one")).toExist(), + ) + }) + test("returning to the tab reads the chains again, so one made in the Create a chain tab can be picked", () => { expect(names(pageUpdate(ready(), Message.ReturnedToTab()).commands)).toEqual([ "FetchAlertEditorChains", diff --git a/apps/web/src/page/alerts/editor/view-notifications.ts b/apps/web/src/page/alerts/editor/view-notifications.ts index 105537337c..a3d0719491 100644 --- a/apps/web/src/page/alerts/editor/view-notifications.ts +++ b/apps/web/src/page/alerts/editor/view-notifications.ts @@ -121,20 +121,24 @@ const chainPicker = ( chainSummary(chain, nameOf), ]), ], - onNone: () => - model.isEscalationChainsFailed && chainId !== null + onNone: () => [ + ...(model.isEscalationChainsFailed ? [ typography(h, { size: "xs", tone: "muted", as: "p" }, [ - "Couldn't load escalation chains; reload the page to change this one", + chainId === null + ? "Couldn't load escalation chains; reload the page to pick one" + : "Couldn't load escalation chains; reload the page to change this one", ]), ] - : chainId === null && destinationIds.length === 0 - ? [ - typography(h, { size: "xs", tone: "destructive", as: "p" }, [ - "Pick a destination or an escalation chain", - ]), - ] - : [], + : []), + ...(chainId === null && destinationIds.length === 0 + ? [ + typography(h, { size: "xs", tone: "destructive", as: "p" }, [ + "Pick a destination or an escalation chain", + ]), + ] + : []), + ], }) return formSelect(h, model.selects[id], id, value, chainOptions(model, chainId), { className: "h-9 w-full", diff --git a/apps/web/src/page/alerts/shared/escalation-chains.ts b/apps/web/src/page/alerts/shared/escalation-chains.ts index 14b79e272c..82a3d643c6 100644 --- a/apps/web/src/page/alerts/shared/escalation-chains.ts +++ b/apps/web/src/page/alerts/shared/escalation-chains.ts @@ -119,7 +119,7 @@ export type RunView = Readonly<{ const ENDED = { resolved: "Ended when the incident resolved", exhausted: "Every step was sent", - superseded: "Replaced by the critical tier's chain", + superseded: "Stopped when the incident changed severity", } as const /** How an incident's run reads in the rail. An acknowledged incident pauses it before the scheduler's next tick does. */ From 9d364afa0a5fb74e33fba4f158eca0ef54c9f5ec Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 00:26:10 +0100 Subject: [PATCH 85/98] fix(web): a window with equal From and To saves as a full day, as the API reads it --- .../web/src/page/alerts/schedule-form/form.ts | 4 +--- .../schedule-form/schedule-form.scene.test.ts | 12 +++++----- .../web/src/page/alerts/schedule-form/view.ts | 22 +++++++++---------- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/apps/web/src/page/alerts/schedule-form/form.ts b/apps/web/src/page/alerts/schedule-form/form.ts index c9a759d8b9..d08691f3a6 100644 --- a/apps/web/src/page/alerts/schedule-form/form.ts +++ b/apps/web/src/page/alerts/schedule-form/form.ts @@ -203,9 +203,7 @@ const restrictionError = (restriction: RestrictionDraft): Option.Option ? Option.some("Pick at least one day") : !isLocalTime(restriction.startTime) || !isLocalTime(restriction.endTime) ? Option.some("Use HH:MM times") - : restriction.endTime === restriction.startTime - ? Option.some("From and To can't be the same time") - : Option.none() + : Option.none() /** The layer's start: the saved instant while untouched, else its text on the zone's clock. */ const layerStartMs = (layer: LayerDraft, timeZone: string): Option.Option => diff --git a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts index 26334f22a1..2f79d19379 100644 --- a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts +++ b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts @@ -245,19 +245,21 @@ describe("schedule form", () => { ) }) - test("a window whose From and To are the same time is refused", () => { + test("a window whose From and To are the same time saves as a full day, and says so", () => { const model = run(ready(), [ + Message.ChangedName({ value: "Checkout primary" }), + addPerson("l0", 0), Message.ClickedAddRestriction({ key: "l0" }), Message.ChangedRestrictionTime({ key: "l0", restrictionKey: "r1", edge: "end", value: "18:00" }), - Message.ClickedSave(), ]) - expect(payloadOf(model.form)).toEqual(Option.none()) + expect( + Option.map(payloadOf(model.form), (payload) => payload.layers[0].restrictions?.[0]?.end_time), + ).toEqual(Option.some("18:00")) scene( { update: pageUpdate, view }, given(model), - expectView(text("From and To can't be the same time")).toExist(), + expectView(text("Runs 24 hours, to the same time the next day")).toExist(), expectView(text("Ends the next morning")).not.toExist(), - expectView(text("Same day")).not.toExist(), ) }) diff --git a/apps/web/src/page/alerts/schedule-form/view.ts b/apps/web/src/page/alerts/schedule-form/view.ts index 9a45e5a439..10f8f3078a 100644 --- a/apps/web/src/page/alerts/schedule-form/view.ts +++ b/apps/web/src/page/alerts/schedule-form/view.ts @@ -303,16 +303,18 @@ const rotationField = (h: H, layer: LayerDraft): Html => { ]) } -/** One weekly window: its days, from and to (an end before the start crosses midnight). */ +/** One weekly window: its days, from and to (an end at or before the start crosses midnight). */ const restrictionRow = (h: H, layer: LayerDraft, restriction: RestrictionDraft): Html => { const key = layer.key const restrictionKey = restriction.key - // Equal times are refused with a field error, so they get no note either. - const isComplete = - isLocalTime(restriction.startTime) && - isLocalTime(restriction.endTime) && - restriction.endTime !== restriction.startTime - const crossesMidnight = isComplete && restriction.endTime < restriction.startTime + const isComplete = isLocalTime(restriction.startTime) && isLocalTime(restriction.endTime) + // Equal times are a full day, as the API reads them: to the same time the next day. + const midnightNote = + restriction.endTime === restriction.startTime + ? "Runs 24 hours, to the same time the next day" + : restriction.endTime < restriction.startTime + ? "Ends the next morning" + : "Same day" return vstack(h, { gap: "2", className: "rounded-md border p-3", attributes: [h.Key(restrictionKey)] }, [ hstack(h, { gap: "2", justify: "between", wrap: true }, [ multiSegmentedSelect(h, { @@ -358,11 +360,7 @@ const restrictionRow = (h: H, layer: LayerDraft, restriction: RestrictionDraft): }), // A time still being typed says nothing about midnight yet. ...(isComplete - ? [ - typography(h, { size: "xs", tone: "muted", className: "pb-2" }, [ - crossesMidnight ? "Ends the next morning" : "Same day", - ]), - ] + ? [typography(h, { size: "xs", tone: "muted", className: "pb-2" }, [midnightNote])] : []), ]), ]) From b466b1c6aa9d433db67e7960339e05c4528ec963 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 01:15:05 +0100 Subject: [PATCH 86/98] fix(web): an anomaly rule's unrouted tier shows no routing error --- .../editor/editor-anomaly.scene.test.ts | 14 +++++++++++++ .../alerts/editor/editor-chains.scene.test.ts | 6 ++++++ .../page/alerts/editor/view-notifications.ts | 21 +++++++++++++++++-- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts b/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts index 259d87d7ce..603a765e60 100644 --- a/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts @@ -75,6 +75,20 @@ describe("alert rule editor: anomaly rules", () => { ) }) + test("a tier left unrouted is no error on an anomaly rule, which only needs someone on either tier", () => { + const anomaly = run(opened({ template: "anomaly" }), [ + Message.ChangedField({ field: "name", value: "Checkout anomalies" }), + Message.ToggledAnomalySignal({ signal: "error_rate" }), + pickExisting("critical", DESTINATION), + ]) + scene( + { update: pageUpdate, view }, + given(anomaly), + themeResolved(), + expectView(text("Pick a destination or an escalation chain")).not.toExist(), + ) + }) + test("picking signals narrows the rule, and saving sends the scope with both pinned tiers", () => { const anomaly = run(opened({ template: "anomaly" }), [ Message.ChangedField({ field: "name", value: "Checkout anomalies" }), diff --git a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts index c6556ec977..8f606a3907 100644 --- a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts @@ -137,6 +137,12 @@ describe("alert rule editor, escalation chains", () => { test("the critical tier needs a destination or a chain of its own", () => { const tiered = run(ready(), [Message.ClickedAddCriticalTier()]) expect(issuesOf(tiered)).toContain("Critical destinations") + scene( + { update: pageUpdate, view }, + given(tiered), + themeResolved(), + expectView(text("Pick a destination or an escalation chain")).toExist(), + ) const routed = run(tiered, [pickChain("critical", chainUuid(2))]) expect(routed.form?.escalation?.escalationChainId).toBe(chainUuid(2)) expect(issuesOf(routed)).not.toContain("Critical destinations") diff --git a/apps/web/src/page/alerts/editor/view-notifications.ts b/apps/web/src/page/alerts/editor/view-notifications.ts index 3b8016544c..b837e6e2ce 100644 --- a/apps/web/src/page/alerts/editor/view-notifications.ts +++ b/apps/web/src/page/alerts/editor/view-notifications.ts @@ -101,6 +101,8 @@ const chainPicker = ( chainId: EscalationChainId | null, destinationIds: ReadonlyArray, label: string, + /** Each tier needs its own route; an anomaly rule only needs someone on either tier. */ + isRouteRequired: boolean, ): Html => { const id = chainSelectIdOf(tier) const ids = idsOf(id, false) @@ -132,7 +134,7 @@ const chainPicker = ( ]), ] : []), - ...(chainId === null && destinationIds.length === 0 + ...(isRouteRequired && chainId === null && destinationIds.length === 0 ? [ typography(h, { size: "xs", tone: "destructive", as: "p" }, [ "Pick a destination or an escalation chain", @@ -173,6 +175,7 @@ const tierRouting = ( destinationIds: ReadonlyArray, chainId: EscalationChainId | null, tiered: boolean, + isRouteRequired: boolean, ): Html => vstack(h, { gap: "3" }, [ destinationList(h, model, tier, destinationIds, destinationsOf(model)), @@ -184,6 +187,7 @@ const tierRouting = ( destinationIds, // Two tiers render two pickers, so each needs its own accessible name. tiered ? `${tier === "warning" ? "Warning" : "Critical"} escalation chain` : "Escalation chain", + isRouteRequired, ), ]) @@ -274,6 +278,7 @@ const messageTemplate = (h: H, model: NotificationsView, form: NotificationsForm const notifications = (h: H, model: NotificationsView, form: NotificationsForm): Html => { const destinations = destinationsOf(model) const escalation = comparatorSupportsTiers(form.comparator) ? form.escalation : null + const isRouteRequired = form.signalType !== "anomaly" const isAnySelected = Arr.some( destinations, (d) => form.destinationIds.includes(d.id) || (escalation?.destinationIds.includes(d.id) ?? false), @@ -303,7 +308,17 @@ const notifications = (h: H, model: NotificationsView, form: NotificationsForm): ] : []), ...(escalation === null - ? [tierRouting(h, model, "warning", form.destinationIds, form.escalationChainId, false)] + ? [ + tierRouting( + h, + model, + "warning", + form.destinationIds, + form.escalationChainId, + false, + isRouteRequired, + ), + ] : [ h.div( [h.Class("mt-1 space-y-4")], @@ -326,6 +341,7 @@ const notifications = (h: H, model: NotificationsView, form: NotificationsForm): form.destinationIds, form.escalationChainId, true, + isRouteRequired, ), ], ), @@ -344,6 +360,7 @@ const notifications = (h: H, model: NotificationsView, form: NotificationsForm): escalation.destinationIds, escalation.escalationChainId, true, + isRouteRequired, ), ], ), From daec1ef357570decd502da60bce13fc770bcb327 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 01:20:00 +0100 Subject: [PATCH 87/98] fix(web): on-call strip survives a failed refresh and an empty inbox; overrides retry; short shift bars stop at the next --- .../page/alerts/hub/hub-oncall.scene.test.ts | 60 +++++++++++++++++++ apps/web/src/page/alerts/hub/update.ts | 3 +- apps/web/src/page/alerts/hub/view.ts | 3 +- apps/web/src/page/alerts/schedule/message.ts | 1 + .../alerts/schedule/schedule.scene.test.ts | 47 ++++++++++++++- apps/web/src/page/alerts/schedule/update.ts | 1 + apps/web/src/page/alerts/schedule/view.ts | 8 ++- packages/views/src/ui/INDEX.md | 2 +- packages/views/src/ui/shift-timeline.test.ts | 19 +++++- packages/views/src/ui/shift-timeline.ts | 30 ++++++++-- 10 files changed, 162 insertions(+), 12 deletions(-) diff --git a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts index b08d1c3740..8a5fcee334 100644 --- a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts @@ -129,6 +129,66 @@ describe("alerts hub, on-call schedules", () => { ).toContain("FetchAlertsHubSchedules") }) + test("a failed refresh keeps the strip, and the clock still reads the schedules again", () => { + const hour = 3_600_000 + const refreshing = pageUpdate( + onTab("inbox", ENGINEER, SCHEDULES), + Message.ReadClock({ nowMs: ONCALL_NOW + 4 * hour }), + ) + expect(names(refreshing.commands)).toContain("FetchAlertsHubSchedules") + const failed = pageUpdate( + refreshing.model, + Message.CompletedFetchSchedules({ + key: refreshing.model.schedules.key ?? "", + readAtMs: ONCALL_NOW + 4 * hour, + result: Result.fail({ + _tag: "@maple/http/errors/AlertPersistenceError", + type: "api_error", + code: "persistence_error", + title: "Couldn't read", + message: "Couldn't read", + retryable: true, + recovery: "retry", + }), + }), + ).model + scene( + { update: pageUpdate, view }, + given(failed), + expectView(role("region", { name: "On call now" })).toExist(), + ) + expect( + names(pageUpdate(failed, Message.ReadClock({ nowMs: ONCALL_NOW + 8 * hour })).commands), + ).toContain("FetchAlertsHubSchedules") + }) + + test("an org with schedules but no rules sees who is on call above the empty inbox", () => { + const entered = init({ _tag: "Alerts", search: { tab: "inbox" } }, context) + const model = Arr.reduce( + [ + Message.ReadClock({ nowMs: ONCALL_NOW }), + Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), + Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + Message.UpdatedDestinations({ orgId: ORG, rows: [] }), + Message.UpdatedRules({ orgId: ORG, rows: [] }), + Message.UpdatedRuleStates({ orgId: ORG, rows: [] }), + Message.UpdatedIncidents({ orgId: ORG, rows: [] }), + Message.CompletedFetchSchedules({ + key: entered.model.schedules.key ?? "", + readAtMs: ONCALL_NOW, + result: Result.succeed(SCHEDULES), + }), + ], + entered.model, + (current, message) => pageUpdate(current, message).model, + ) + scene( + { update: pageUpdate, view }, + given(model), + expectView(role("region", { name: "On call now" })).toExist(), + ) + }) + test("a chain's summary names the people and schedules it pages", () => { 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 7a141b90ab..368d04d123 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -215,7 +215,8 @@ const schedulesOf = (model: Model): ReadonlyArray => const isSchedulesStale = (model: Model, nowMs: number): boolean => { const readAt = model.schedules.updatedAtMs - if (readAt === undefined || !AsyncData.isSuccess(model.schedules.data)) return false + // A failed refresh keeps the last list (`Stale`), which still ages and hands off. + if (readAt === undefined || Option.isNone(AsyncData.getData(model.schedules.data))) return false return ( nowMs - readAt >= SCHEDULES_MAX_AGE_MS || Array.some(schedulesOf(model), (schedule) => Option.exists(handoffMs(schedule), (at) => at <= nowMs)) diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index b87d81bdd6..784c835acf 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -315,8 +315,9 @@ const hubBody = ( if (tab === "inbox") { const signal = resolveSignal(model.search.signal) const hasAnomalies = Arr.some(data.incidents, (incident) => incident.signalType === "anomaly") + // A team can set up on-call before its first rule, so the strip shows on the empty inbox too. if (data.rules.length === 0 && !hasManagedIncidents && !hasAnomalies && signal === null) - return emptyState + return h.div([h.Class("space-y-6")], [...whoIsOnCallStrip(h, model), emptyState]) const filters = inboxFilters(h, { signal, status: resolveIncidentStatus(model.search.anomalyStatus), diff --git a/apps/web/src/page/alerts/schedule/message.ts b/apps/web/src/page/alerts/schedule/message.ts index 68944ecdaf..cf1187acc6 100644 --- a/apps/web/src/page/alerts/schedule/message.ts +++ b/apps/web/src/page/alerts/schedule/message.ts @@ -22,6 +22,7 @@ export const Message = defineMessageUnion({ CompletedFetchSession: { orgId: Schema.String, grants: Schema.Array(Policy.PermissionName) }, ClickedRetry: {}, ClickedRetryShifts: {}, + ClickedRetryOverrides: {}, ClickedPreviousWeek: {}, ClickedNextWeek: {}, ClickedThisWeek: {}, diff --git a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts index d187433e63..c7e17b2472 100644 --- a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts +++ b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts @@ -100,7 +100,7 @@ describe("on-call schedule page", () => { expectView(role("rowheader", { name: /Weeknights/u })).toExist(), expectView(role("button", { name: /^Override: Ana Souza, Wed,? 11 Nov/u })).toExist(), expectView(text("Upcoming overrides")).toExist(), - expectView(role("button", { name: "Delete Dana Kim's override" })).toExist(), + expectView(role("button", { name: /^Delete Dana Kim's override, /u })).toExist(), ) }) @@ -282,6 +282,49 @@ describe("on-call schedule page", () => { ) }) + test("a failed overrides read offers Try again, which reads them again", () => { + const entered = init({ _tag: "AlertsSchedule", scheduleId: schedulePublicId(1), search: {} }, context) + const failed = run(entered.model, [ + Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), + Message.ReadClock({ nowMs: ONCALL_NOW }), + Message.CompletedFetchSchedule({ + key: entered.model.schedule.key ?? "", + readAtMs: ONCALL_NOW, + result: Result.succeed(checkoutSchedule(ONCALL_NOW)), + }), + Message.CompletedFetchOverrides({ + key: entered.model.overrides.key ?? "", + result: Result.fail({ + _tag: "@maple/http/errors/AlertPersistenceError", + type: "api_error", + code: "persistence_error", + title: "Couldn't read", + message: "Couldn't read", + retryable: false, + recovery: "retry", + }), + }), + ]) + scene( + { update: pageUpdate, view }, + given(withShifts(failed, ONCALL_NOW)), + expectView(text("Couldn't load the overrides")).toExist(), + expectView(role("button", { name: /Try again/u })).toExist(), + ) + expect(names(pageUpdate(failed, Message.ClickedRetryOverrides()).commands)).toContain( + "FetchOnCallOverrides", + ) + }) + + test("each override's delete button names the window it removes", () => { + scene( + { update: pageUpdate, view }, + given(loaded(ONCALL_NOW)), + expectView(role("button", { name: /^Delete .+'s override, .+/u })).toExist(), + ) + }) + test("deleting an override asks first, sends one delete, and reads the list again even when it fails", () => { const overrideId = Schema.decodeSync(OnCallOverrideId)(overrideUuid(1)) const asked = pageUpdate(loaded(ONCALL_NOW), Message.ClickedDeleteOverride({ overrideId })) @@ -316,7 +359,7 @@ describe("on-call schedule page", () => { { update: pageUpdate, view }, given(loaded(ONCALL_NOW, MEMBER)), expectView(role("button", { name: "Add override" })).not.toExist(), - expectView(role("button", { name: "Delete Dana Kim's override" })).not.toExist(), + expectView(role("button", { name: /^Delete Dana Kim's override, /u })).not.toExist(), expectView(text("Times in Europe/Berlin. Yours is UTC.")).toExist(), ) }) diff --git a/apps/web/src/page/alerts/schedule/update.ts b/apps/web/src/page/alerts/schedule/update.ts index cd24f0c2f9..242d2b50a1 100644 --- a/apps/web/src/page/alerts/schedule/update.ts +++ b/apps/web/src/page/alerts/schedule/update.ts @@ -313,6 +313,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re "Load", ), ClickedRetryShifts: () => requestShifts(model, context, "Supersede"), + ClickedRetryOverrides: () => refreshOverrides(model, context), CompletedFetchShifts: ({ key, result }) => ({ model: { ...model, shifts: Keyed.accept(model.shifts, key, result) }, }), diff --git a/apps/web/src/page/alerts/schedule/view.ts b/apps/web/src/page/alerts/schedule/view.ts index a235f969c8..65e21a14f8 100644 --- a/apps/web/src/page/alerts/schedule/view.ts +++ b/apps/web/src/page/alerts/schedule/view.ts @@ -214,7 +214,7 @@ const overrideRow = (h: H, model: Model, schedule: V2OnCallSchedule, override: V iconButton( h, { - label: `Delete ${name}'s override`, + label: `Delete ${name}'s override, ${rangeText(isoMs(override.starts_at), isoMs(override.ends_at), schedule.time_zone)}`, isDisabled: model.deletingOverrideId === override.id, className: "text-muted-foreground", attributes: [ @@ -237,7 +237,11 @@ const overridesSection = (h: H, model: Model, schedule: V2OnCallSchedule): Html [ sectionHeading(h, { title: "Upcoming overrides" }), AsyncData.isFailure(model.overrides.data) - ? errorState(h, { failure: model.overrides.data.error, title: "Couldn't load the overrides" }) + ? errorState(h, { + failure: model.overrides.data.error, + title: "Couldn't load the overrides", + onRetry: Message.ClickedRetryOverrides(), + }) : overrides.length === 0 ? typography(h, { size: "sm", tone: "muted", as: "p" }, [ AsyncData.isLoading(model.overrides.data) diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 84969217b9..490ce2e509 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -141,7 +141,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/selectable-row.ts` `selectableRow`: `SelectableRow`: a bespoke row's frame (a `div`, or a link with `href`) with `listRow`'s selection lane and tint; a clickable one is a `pressable` with `selectedLane`. - `packages/views/src/ui/setting-row.ts` `settingRow`: Port of packages/ui/src/components/ui/setting-row.tsx. - `packages/views/src/ui/sheet.ts` `leftSheet`: `` (packages/ui/src/components/ui/sheet.tsx) over Base UI Dialog. -- `packages/views/src/ui/shift-timeline.ts` `zonedDayStart`, `placeBar`, `timelineDays`, `shiftTimeline`: `ShiftTimeline`: rows of time bars over a day-ruled axis in one time zone, with a now line (an on-call schedule's layers and its final row). +- `packages/views/src/ui/shift-timeline.ts` `zonedDayStart`, `placeBar`, `timelineDays`, `barRoomPct`, `shiftTimeline`: `ShiftTimeline`: rows of time bars over a day-ruled axis in one time zone, with a now line (an on-call schedule's layers and its final row). - `packages/views/src/ui/side-panel.ts` `sidePanelFrame`: A detail side panel's frame (span, service, database): a full-height clipped column on the page background. - `packages/views/src/ui/sidebar.ts` `sidebarWrapper`, `sidebarDesktop`, `sidebarMobile`, `sidebarHeader`, `sidebarFooter`, `sidebarContent`, `sidebarGroup`, `sidebarGroupLabel`, `sidebarGroupContent`, `sidebarGroupAction`, `sidebarSeparator`, `sidebarMenu`, `sidebarMenuItem`, `sidebarMenuBadge`, `sidebarMenuSub`, `sidebarMenuSubItem`, `isLinkActive`, `sidebarMenuButton`, `sidebarMenuSubButton`, `sidebarRail`, `sidebarInset`, `sidebarTrigger`: The Foldkit side of packages/ui/src/components/ui/sidebar.tsx: same markup, same class composition. - `packages/views/src/ui/signal-empty-state.ts` `emptyActions`, `filteredEmpty`, `presenceLoading`, `signalEmptyState`: `SignalEmptyState` and `FilteredEmpty`. diff --git a/packages/views/src/ui/shift-timeline.test.ts b/packages/views/src/ui/shift-timeline.test.ts index 15eb3bb6a4..a34045cf4f 100644 --- a/packages/views/src/ui/shift-timeline.test.ts +++ b/packages/views/src/ui/shift-timeline.test.ts @@ -1,6 +1,6 @@ import { Option, Array as Arr } from "effect" import { describe, expect, test } from "vitest" -import { placeBar, timelineDays, zonedDayStart } from "./shift-timeline" +import { barRoomPct, placeBar, timelineDays, zonedDayStart } from "./shift-timeline" const HOUR = 3_600_000 @@ -11,6 +11,23 @@ describe("shift timeline layout", () => { expect(placeBar(0, 100, 100, 150)).toEqual(Option.none()) }) + test("a short bar's floor stops at the next bar in its lane, and the last one at the range's end", () => { + const bar = (key: string, startMs: number, endMs: number) => ({ + key, + startMs, + endMs, + label: key, + title: key, + tone: "neutral" as const, + }) + const week = 168 * HOUR + const hourLong = bar("a", 0, HOUR) + const next = bar("b", HOUR, 24 * HOUR) + const lane = [next, hourLong] + expect(barRoomPct(0, week, lane, hourLong)).toBeCloseTo((HOUR / week) * 100) + expect(barRoomPct(0, week, lane, next)).toBe(100) + }) + test("days follow the zone's wall clock: the Berlin fall-back Sunday is 25 hours wide", () => { const from = Date.parse("2026-10-22T22:00:00.000Z") // Fri 23 Oct, 00:00 in Berlin const to = from + 7 * 24 * HOUR + HOUR diff --git a/packages/views/src/ui/shift-timeline.ts b/packages/views/src/ui/shift-timeline.ts index 9ed016feef..ebbd1c7279 100644 --- a/packages/views/src/ui/shift-timeline.ts +++ b/packages/views/src/ui/shift-timeline.ts @@ -1,6 +1,6 @@ import { zonedDateParts, zonedPartsToEpochMs } from "@maple/query-engine/datetime" import { cn } from "@maple/ui/lib/utils" -import { Array as Arr, Option } from "effect" +import { Array as Arr, Option, Result } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { pressable } from "./pressable" import { hstack, vstack } from "./stack" @@ -113,20 +113,40 @@ const FOCUS_CLASS = "focus-visible:outline-none focus-visible:ring-2 focus-visib const BAR_CLASS = "absolute inset-y-1 overflow-hidden rounded-sm border px-1.5 text-left" +/** How far a bar may grow to reach its floor: up to the next bar's start in the lane, or the range's end. */ +export const barRoomPct = ( + fromMs: number, + toMs: number, + bars: ReadonlyArray>, + bar: TimelineBar, +) => { + const starts = Arr.filterMap(bars, (other) => + other.startMs > bar.startMs + ? Option.match(placeBar(fromMs, toMs, other.startMs, other.endMs), { + onNone: () => Result.failVoid, + onSome: ({ leftPct }) => Result.succeed(leftPct), + }) + : Result.failVoid, + ) + return Arr.reduce(starts, 100, (room, start) => Math.min(room, start)) +} + const barView = ( h: HtmlBuilder, fromMs: number, toMs: number, bar: TimelineBar, + limitPct: number, ): ReadonlyArray => Option.match(placeBar(fromMs, toMs, bar.startMs, bar.endMs), { onNone: () => [], onSome: ({ leftPct, widthPct }) => { - // A 2px gutter keeps back-to-back shifts apart; a floor keeps an hour-long one hittable. + // A 2px gutter keeps back-to-back shifts apart; a floor keeps an hour-long one hittable, but + // never reaches past the next bar's start. const style = h.Style({ left: `${leftPct}%`, width: `calc(${widthPct}% - 2px)`, - "min-width": `${MIN_BAR_PX}px`, + "min-width": `min(${MIN_BAR_PX}px, calc(${limitPct - leftPct}% - 2px))`, }) const text = typography(h, { size: "xs", truncate: true, className: "block leading-6" }, [ bar.label, @@ -282,7 +302,9 @@ export const shiftTimeline = ( [row.emptyText], ), ] - : Arr.flatMap(row.bars, (bar) => barView(h, fromMs, toMs, bar)), + : Arr.flatMap(row.bars, (bar) => + barView(h, fromMs, toMs, bar, barRoomPct(fromMs, toMs, row.bars, bar)), + ), ), ], ) From 4cfbd483df782c734b63335f1a774dbd11e4ac97 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 01:24:59 +0100 Subject: [PATCH 88/98] fix(alerts): an acked run reads paused at once; the incident rail rereads a due step; one run per incident in the list read --- .../api/src/routes/v2/alert-incidents.http.ts | 36 ++++++++++------ apps/api/src/routes/v2/alerts.http.test.ts | 8 ++++ .../incident-escalation.scene.test.ts | 17 ++++++++ apps/web/src/page/alerts/incident/update.ts | 24 ++++++++++- .../services/alerts/AlertReadModelsService.ts | 43 ++++++++++--------- 5 files changed, 94 insertions(+), 34 deletions(-) diff --git a/apps/api/src/routes/v2/alert-incidents.http.ts b/apps/api/src/routes/v2/alert-incidents.http.ts index be591dc04d..d0dbd8bae6 100644 --- a/apps/api/src/routes/v2/alert-incidents.http.ts +++ b/apps/api/src/routes/v2/alert-incidents.http.ts @@ -22,18 +22,28 @@ import { import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" import { CurrentAuditActor } from "@maple/backend/services/auth/audit-actor" -const toV2Escalation = (doc: AlertIncidentEscalationDocument): V2AlertIncidentEscalation => ({ - chain_id: doc.chainId, - chain_name: doc.chainName, - status: doc.status, - // The scheduler leaves an exhausted run past its last step; the wire never reads past the end. - step: Math.min(doc.stepIndex + 1, doc.stepCount), - step_count: doc.stepCount, - pass: Math.min(doc.passIndex + 1, doc.repeatCount + 1), - pass_count: doc.repeatCount + 1, - steps_sent: doc.stepsSent, - next_step_at: doc.nextStepAt, -}) +/** + * An ack pauses the run on the scheduler's next tick; until then the wire already reads it paused, + * so a caller never sees a page coming that will not. + */ +const toV2Escalation = ( + doc: AlertIncidentEscalationDocument, + isAcknowledged: boolean, +): V2AlertIncidentEscalation => { + const isPausing = isAcknowledged && doc.status === "active" + return { + chain_id: doc.chainId, + chain_name: doc.chainName, + status: isPausing ? "acknowledged" : doc.status, + // The scheduler leaves an exhausted run past its last step; the wire never reads past the end. + step: Math.min(doc.stepIndex + 1, doc.stepCount), + step_count: doc.stepCount, + pass: Math.min(doc.passIndex + 1, doc.repeatCount + 1), + pass_count: doc.repeatCount + 1, + steps_sent: doc.stepsSent, + next_step_at: isPausing ? null : doc.nextStepAt, + } +} const toV2Incident = ( doc: AlertIncidentDocument, @@ -72,7 +82,7 @@ const toV2Incident = ( : { user_id: doc.acknowledgedByUserId, name: doc.acknowledgedByName }, acknowledged_via: doc.acknowledgedVia, error_issue_id: doc.errorIssueId, - escalation: escalation === undefined ? null : toV2Escalation(escalation), + escalation: escalation === undefined ? null : toV2Escalation(escalation, doc.acknowledgedAt !== null), ...(doc.anomalyIncidentId === undefined ? undefined : { anomaly_incident_id: doc.anomalyIncidentId }), ...(doc.groupLabel === undefined ? undefined : { group_label: doc.groupLabel }), ...(doc.labels === undefined ? undefined : { labels: doc.labels }), diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index 9925468b59..0e5871f01b 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -1440,6 +1440,14 @@ describe("v2 alerts over HTTP", () => { expect(retrieved.status).toBe(200) expect(retrieved.body.escalation).toEqual(expected) + // Acked before the scheduler pauses the run: it already reads paused, with no next page. + await executeSql( + harness.testDb, + `update alert_incidents set acknowledged_at = now() where id = '00000000-0000-4000-8000-0000000000b1'`, + ) + const pausing = await harness.request("GET", `/v2/alerts/incidents/${withRun.id}`, key.secret) + expect(pausing.body.escalation).toMatchObject({ status: "acknowledged", next_step_at: null }) + // Paused while acknowledged: no next page time. await executeSql( harness.testDb, diff --git a/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts b/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts index ae894b7943..bd7393322c 100644 --- a/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts +++ b/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts @@ -55,6 +55,23 @@ describe("alert incident, escalation", () => { expect(names(acked.commands)).toContain("FetchAlertIncidentEscalation") }) + test("the clock reads a running chain again once its step is due, which pages people the rows do not show", () => { + const running = withRun( + synced(), + runOf("active", { nextStepAt: new Date(NOW + 10 * 60_000).toISOString() }), + ) + expect( + names(pageUpdate(running, Message.TickedClock({ nowMs: NOW + 5 * 60_000 })).commands), + ).not.toContain("FetchAlertIncidentEscalation") + expect( + names(pageUpdate(running, Message.TickedClock({ nowMs: NOW + 11 * 60_000 })).commands), + ).toContain("FetchAlertIncidentEscalation") + const finished = withRun(synced(), runOf("exhausted")) + expect( + names(pageUpdate(finished, Message.TickedClock({ nowMs: NOW + 60 * 60_000 })).commands), + ).not.toContain("FetchAlertIncidentEscalation") + }) + test("a running chain shows its step and when it pages next", () => { scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/alerts/incident/update.ts b/apps/web/src/page/alerts/incident/update.ts index 3f14ebecaa..cf44fcbdb9 100644 --- a/apps/web/src/page/alerts/incident/update.ts +++ b/apps/web/src/page/alerts/incident/update.ts @@ -483,10 +483,32 @@ const withIncidents = ( return isChanged ? { ...next, commands: [...(next.commands ?? []), ReadIncidentClock()] } : next } +/** A step that only pages people changes no synced row, so the clock re-reads a run once its step is due. */ +const refreshDueEscalation = (model: Model, nowMs: number): Return => { + const key = model.escalation.key + const run = Option.getOrNull(AsyncData.getData(model.escalation.data)) + const isDue = + key !== null && + run !== null && + run.status === "active" && + run.next_step_at !== null && + isoMs(run.next_step_at) <= nowMs + const view = incidentViewOf(model) + if (!isDue || Option.isNone(view)) return { model } + const incidentId = view.value.incident.id + return Option.match(Keyed.request(model.escalation, key, "Refresh"), { + onNone: () => ({ model }), + onSome: (escalation): Return => ({ + model: { ...model, escalation }, + commands: [FetchIncidentEscalation({ key: escalation.key, incidentId })], + }), + }) +} + 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 } }), + TickedClock: ({ nowMs }) => refreshDueEscalation({ ...model, clockMs: nowMs }, 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/packages/backend/src/services/alerts/AlertReadModelsService.ts b/packages/backend/src/services/alerts/AlertReadModelsService.ts index 023704a945..9843d48fe6 100644 --- a/packages/backend/src/services/alerts/AlertReadModelsService.ts +++ b/packages/backend/src/services/alerts/AlertReadModelsService.ts @@ -47,7 +47,6 @@ import { Effect, Layer, Option, - Record as Rec, Schema, } from "effect" import { Database } from "@maple/backend/platform/DatabaseLive" @@ -300,28 +299,32 @@ export class AlertReadModelsService extends Context.Service< const rows = yield* dbExecute((db) => db.run( PG.from(AlertEscalationRuns) - .select( - "id", - "createdAt", - "incidentId", - "chainId", - "chainName", - "status", - "stepIndex", - "passIndex", - "stepsJson", - "repeatCount", - "stepsSent", - "nextStepAt", - ) + .select(($) => ({ + incidentId: $.incidentId, + chainId: $.chainId, + chainName: $.chainName, + status: $.status, + stepIndex: $.stepIndex, + passIndex: $.passIndex, + // Counted in SQL, so the list never loads the steps themselves. + stepCount: PG.sql(PG.int4)`jsonb_array_length(${$.stepsJson})::int`, + repeatCount: $.repeatCount, + stepsSent: $.stepsSent, + nextStepAt: $.nextStepAt, + })) + // One row per incident: its latest run (severity changes leave older ones behind). + .distinctOn("incidentId") .where(($) => [$.orgId.eq(orgId), $.incidentId.in_(...incidentIds)]) - .orderBy(["createdAt", "desc"], ["id", "desc"]), + .orderBy(($) => [ + [$.incidentId, "asc"], + [$.createdAt, "desc"], + [$.id, "desc"], + ]), ), ) - // Newest first, so each incident's group starts with its latest run. return Arr.map( - Rec.values(Arr.groupBy(rows, (row) => row.incidentId)), - ([row]) => + rows, + (row) => new AlertIncidentEscalationDocument({ incidentId: row.incidentId, chainId: row.chainId, @@ -329,7 +332,7 @@ export class AlertReadModelsService extends Context.Service< status: row.status, stepIndex: row.stepIndex, passIndex: row.passIndex, - stepCount: row.stepsJson.length, + stepCount: row.stepCount, repeatCount: row.repeatCount, stepsSent: row.stepsSent, nextStepAt: row.status === "active" ? toIso(row.nextStepAt) : null, From 80a4d9641a6447bdf6f4a8991f4ba8582c246fb6 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 01:26:35 +0100 Subject: [PATCH 89/98] fix(web): a failed members read is asked again on the next route change --- .../web/src/page/alerts/hub/hub-oncall.scene.test.ts | 12 ++++++++++++ apps/web/src/page/alerts/hub/update.ts | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts index 8a5fcee334..a05ccc85f0 100644 --- a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts @@ -189,6 +189,18 @@ describe("alerts hub, on-call schedules", () => { ) }) + test("a failed members read is asked again on the next route change", () => { + const entered = init({ _tag: "Alerts", search: { tab: "inbox" } }, context) + expect(names(entered.commands)).toContain("FetchAlertsHubMembers") + const failed = pageUpdate( + entered.model, + Message.CompletedFetchMembers({ result: Result.fail("Clerk is down") }), + ).model + expect( + names(routeChanged(failed, { _tag: "Alerts", search: { tab: "oncall" } }, context).commands), + ).toContain("FetchAlertsHubMembers") + }) + test("a chain's summary names the people and schedules it pages", () => { 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 368d04d123..7060eb6a09 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -1057,7 +1057,8 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedFetchMembers: ({ result }) => Result.match(result, { onSuccess: (members) => ({ model: { ...model, members } }), - onFailure: () => ({ model }), + // A failed read is asked again on the next tab or route change. + onFailure: () => ({ model: { ...model, isMembersRequested: false } }), }), ClickedConfirmDeleteSchedule: () => confirmDeleteSchedule(model), CompletedDeleteSchedule: ({ scheduleId, result }) => From 548b0f758c09201f92e2b2720c059dc37a164aa5 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 01:33:09 +0100 Subject: [PATCH 90/98] fix(web): a refused chain delete rereads the chains and counts its blockers honestly; the description field rebuilds per open --- .../src/page/alerts/escalation-dialog/view.ts | 2 ++ .../page/alerts/hub/hub-chains.scene.test.ts | 2 ++ apps/web/src/page/alerts/hub/update.ts | 23 +++++++++---------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/web/src/page/alerts/escalation-dialog/view.ts b/apps/web/src/page/alerts/escalation-dialog/view.ts index 30d6ecae8f..eb624f216e 100644 --- a/apps/web/src/page/alerts/escalation-dialog/view.ts +++ b/apps/web/src/page/alerts/escalation-dialog/view.ts @@ -297,6 +297,8 @@ const descriptionField = (h: H, model: Model): Html => { defaultValue: model.description, isDisabled: model.isSaving, attributes: [ + // Uncontrolled: keyed on the open, so a reopened dialog rebuilds it with that chain's text. + h.Key(`${ids.control}-${model.openCount}`), h.AriaLabelledBy(ids.label), h.Placeholder("Optional: who is on this chain and when to change it"), h.Rows(2), diff --git a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts index bd3429bee4..d7fa744225 100644 --- a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts @@ -164,6 +164,8 @@ describe("alerts hub, escalation chains", () => { "Still used by Checkout errors, Checkout p95. Remove it from their severity tiers first.", ) expect(refused.model.confirmDeleteChainId).toBe(chainId) + // The chains are read again, so a retry names a rule attached since the tab opened. + expect(names(refused.commands)).toContain("FetchAlertsHubChains") // Before the rules list has loaded the rules cannot be named, only counted. const unloaded = pageUpdate( diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 273644607e..b56cf14729 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -400,11 +400,12 @@ const inUseMessage = (model: Model, chainId: EscalationChainId): string => { names.length, ) const others = total - names.length - const tiers = total === 1 ? "its severity tiers" : "their severity tiers" + const tiers = total > 1 ? "their severity tiers" : "its severity tiers" + // No count when the cached chain names none: one rule is the usual blocker, not several. if (names.length === 0) { - return total === 1 - ? "An alert rule still starts this chain. Remove it from its severity tiers first." - : `Alert rules still start this chain. Remove it from ${tiers} first.` + return total > 1 + ? `Alert rules still start this chain. Remove it from ${tiers} first.` + : "An alert rule still starts this chain. Remove it from its severity tiers first." } const listed = others === 0 ? Array.join(names, ", ") : `${Array.join(names, ", ")} and ${others} more` return `Still used by ${listed}. Remove it from ${tiers} first.` @@ -468,14 +469,12 @@ const deletedChain = ( outMessage: toast("success", "Escalation chain deleted"), } }, - // The dialog stays open on a refusal, saying which rules hold the chain. - onFailure: (failure): Return => ({ - model: { - ...settled, - deleteChainError: - failure.code === CHAIN_IN_USE_CODE ? inUseMessage(settled, chainId) : failure.message, - }, - }), + // The dialog stays open on a refusal, saying which rules hold the chain. The chains are read + // again, so a retry names a rule attached since the tab opened. + onFailure: (failure): Return => + failure.code === CHAIN_IN_USE_CODE + ? refreshChains({ ...settled, deleteChainError: inUseMessage(settled, chainId) }, context) + : { model: { ...settled, deleteChainError: failure.message } }, }) } From fbba600744914f71b9154ce04fa5a08ed9d6da68 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 01:35:55 +0100 Subject: [PATCH 91/98] fix(web): the chain picker is described by and marked invalid with its routing error --- .../page/alerts/editor/editor-chains.scene.test.ts | 1 + .../src/page/alerts/editor/view-notifications.ts | 14 +++++++++----- apps/web/src/page/alerts/editor/view-select.ts | 3 +++ packages/views/src/ui/select-view.ts | 3 +++ 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts index 8f606a3907..26d0b8a590 100644 --- a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts @@ -86,6 +86,7 @@ describe("alert rule editor, escalation chains", () => { given(model), themeResolved(), expectView(text("Pick a destination or an escalation chain")).toExist(), + expectView(role("combobox", { name: "Escalation chain" })).toHaveAttr("aria-invalid", "true"), ) }) diff --git a/apps/web/src/page/alerts/editor/view-notifications.ts b/apps/web/src/page/alerts/editor/view-notifications.ts index b837e6e2ce..1c2fc2ca65 100644 --- a/apps/web/src/page/alerts/editor/view-notifications.ts +++ b/apps/web/src/page/alerts/editor/view-notifications.ts @@ -107,7 +107,8 @@ const chainPicker = ( const id = chainSelectIdOf(tier) const ids = idsOf(id, false) const value = chainId ?? NO_CHAIN - const state = fieldStateOf(value, undefined) + const isUnrouted = isRouteRequired && chainId === null && destinationIds.length === 0 + const state = { ...fieldStateOf(value, undefined), isInvalid: isUnrouted } const destinations = destinationsOf(model) const nameOf = (destinationId: AlertDestinationId) => Option.getOrElse( @@ -134,11 +135,13 @@ const chainPicker = ( ]), ] : []), - ...(isRouteRequired && chainId === null && destinationIds.length === 0 + ...(isUnrouted ? [ - typography(h, { size: "xs", tone: "destructive", as: "p" }, [ - "Pick a destination or an escalation chain", - ]), + typography( + h, + { size: "xs", tone: "destructive", as: "p", attributes: [h.Id(ids.error)] }, + ["Pick a destination or an escalation chain"], + ), ] : []), ], @@ -146,6 +149,7 @@ const chainPicker = ( return formSelect(h, model.selects[id], id, value, chainOptions(model, chainId), { className: "h-9 w-full", labelledBy: ids.label, + ...(isUnrouted ? { errorId: ids.error } : undefined), toView: (parts) => field(h, { className: "items-stretch gap-1.5", state }, [ hstack(h, { gap: "2", justify: "between" }, [ diff --git a/apps/web/src/page/alerts/editor/view-select.ts b/apps/web/src/page/alerts/editor/view-select.ts index 2c6477fd56..b102b291ca 100644 --- a/apps/web/src/page/alerts/editor/view-select.ts +++ b/apps/web/src/page/alerts/editor/view-select.ts @@ -17,6 +17,8 @@ export const formSelect = ( className?: string ariaLabel?: string labelledBy?: string + /** The error text the trigger is described by, and marked invalid with. */ + errorId?: string toView: (parts: ReadonlyArray) => Html }>, ): Html => { @@ -32,6 +34,7 @@ export const formSelect = ( className: inputs.className, ariaLabel: inputs.ariaLabel, labelledBy: inputs.labelledBy, + ...(inputs.errorId === undefined ? undefined : { describedBy: inputs.errorId, isInvalid: true }), toView: inputs.toView, }, }) diff --git a/packages/views/src/ui/select-view.ts b/packages/views/src/ui/select-view.ts index 66ae728ba9..7dc9c6d2c0 100644 --- a/packages/views/src/ui/select-view.ts +++ b/packages/views/src/ui/select-view.ts @@ -51,6 +51,8 @@ export type ViewInputs = Readonly<{ /** Inside a Base UI field: the field label (and description) that name the trigger. */ labelledBy?: string describedBy?: string + /** The field shows an error: the trigger reads `aria-invalid`. */ + isInvalid?: boolean /** ``. */ size?: "sm" | "default" | "lg" className?: string @@ -298,6 +300,7 @@ export const view = Submodel.defineView((model, inpu ...(inputs.ariaLabel === undefined ? [] : [h.AriaLabel(inputs.ariaLabel)]), ...(inputs.labelledBy === undefined ? [] : [h.AriaLabelledBy(inputs.labelledBy)]), ...(inputs.describedBy === undefined ? [] : [h.AriaDescribedBy(inputs.describedBy)]), + ...(inputs.isInvalid === true ? [h.AriaInvalid(true)] : []), h.Class(cn(selectTriggerVariants({ size: inputs.size ?? "default" }), inputs.className)), ...(isDisabled ? [h.DataAttribute("disabled", ""), h.Disabled(true)] : []), ...(model.isOpen ? [h.DataAttribute("popup-open", "")] : []), From c12d4b26ed6ac568ca8f6806ecd44317555d0f11 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 01:37:29 +0100 Subject: [PATCH 92/98] fix(web): the schedule form's add, remove and window buttons name their layer --- .../schedule-form/schedule-form.scene.test.ts | 2 ++ apps/web/src/page/alerts/schedule-form/view.ts | 14 ++++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts index 2f79d19379..7cd64e2679 100644 --- a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts +++ b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts @@ -126,6 +126,7 @@ describe("schedule form", () => { { update: pageUpdate, view }, given(model), expectView(role("button", { name: `Move ${oncallMembers[2]?.name} up in layer 1` })).toExist(), + expectView(role("button", { name: `Remove ${oncallMembers[2]?.name} in layer 1` })).toExist(), ) expect(names(pageUpdate(model, Message.ClickedSave()).commands)).toEqual(["SaveOnCallSchedule"]) }) @@ -259,6 +260,7 @@ describe("schedule form", () => { { update: pageUpdate, view }, given(model), expectView(text("Runs 24 hours, to the same time the next day")).toExist(), + expectView(role("button", { name: "Remove this window from layer 1" })).toExist(), expectView(text("Ends the next morning")).not.toExist(), ) }) diff --git a/apps/web/src/page/alerts/schedule-form/view.ts b/apps/web/src/page/alerts/schedule-form/view.ts index 10f8f3078a..3747aac0e4 100644 --- a/apps/web/src/page/alerts/schedule-form/view.ts +++ b/apps/web/src/page/alerts/schedule-form/view.ts @@ -192,7 +192,7 @@ const participantsField = (h: H, model: Model, layer: LayerDraft, index: number) Option.map(memberOf(model.members, userId), (member) => member.imageUrl), ), }), - removeLabel: `Remove ${name} from layer ${index + 1}`, + removeLabel: `Remove ${name}`, onRemove: Message.ClickedRemoveParticipant({ key, userId }), move: { context: `layer ${index + 1}`, @@ -215,6 +215,8 @@ const participantsField = (h: H, model: Model, layer: LayerDraft, index: number) toMenuMessage: (message) => Message.GotParticipantMenuMessage({ key, message }), addEntries, addLabel: "Add person", + // Names the layer in its buttons: "Remove Ben in layer 2", "Add person to layer 2". + context: `layer ${index + 1}`, }), ]) } @@ -304,7 +306,7 @@ const rotationField = (h: H, layer: LayerDraft): Html => { } /** One weekly window: its days, from and to (an end at or before the start crosses midnight). */ -const restrictionRow = (h: H, layer: LayerDraft, restriction: RestrictionDraft): Html => { +const restrictionRow = (h: H, layer: LayerDraft, layerIndex: number, restriction: RestrictionDraft): Html => { const key = layer.key const restrictionKey = restriction.key const isComplete = isLocalTime(restriction.startTime) && isLocalTime(restriction.endTime) @@ -332,7 +334,7 @@ const restrictionRow = (h: H, layer: LayerDraft, restriction: RestrictionDraft): iconButton( h, { - label: "Remove this window", + label: `Remove this window from layer ${layerIndex + 1}`, className: "text-muted-foreground", attributes: [h.OnClick(Message.ClickedRemoveRestriction({ key, restrictionKey }))], }, @@ -366,7 +368,7 @@ const restrictionRow = (h: H, layer: LayerDraft, restriction: RestrictionDraft): ]) } -const restrictionsField = (h: H, model: Model, layer: LayerDraft): Html => { +const restrictionsField = (h: H, model: Model, layer: LayerDraft, layerIndex: number): Html => { const errors = layerErrors(layer, model.form.timeZone).restrictions return vstack(h, { gap: "2" }, [ typography(h, { size: "xs", tone: "muted", as: "p" }, [ @@ -375,7 +377,7 @@ const restrictionsField = (h: H, model: Model, layer: LayerDraft): Html => { : "Only active in these windows", ]), ...Arr.flatMap(layer.restrictions, (restriction, index) => [ - restrictionRow(h, layer, restriction), + restrictionRow(h, layer, layerIndex, restriction), ...shown(h, model, errors[index] ?? Option.none()), ]), button( @@ -460,7 +462,7 @@ const layerBlock = (h: H, model: Model, layer: LayerDraft, index: number): Html onInput: layerText(key, "startsAt"), }), ...shown(h, model, errors.startsAt), - restrictionsField(h, model, layer), + restrictionsField(h, model, layer, index), ]) } From f92b11267e715c28de2d7b0018fbe6b808ef3ea2 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 01:44:11 +0100 Subject: [PATCH 93/98] fix(web): a refused chain delete's message is rebuilt from the chains read after it --- .../page/alerts/hub/hub-chains.scene.test.ts | 11 ++++++ apps/web/src/page/alerts/hub/model.ts | 2 ++ apps/web/src/page/alerts/hub/update.ts | 34 +++++++++++++++---- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts index d7fa744225..0ef298a0bb 100644 --- a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts @@ -166,6 +166,17 @@ describe("alerts hub, escalation chains", () => { expect(refused.model.confirmDeleteChainId).toBe(chainId) // The chains are read again, so a retry names a rule attached since the tab opened. expect(names(refused.commands)).toContain("FetchAlertsHubChains") + const reread = pageUpdate( + refused.model, + Message.CompletedFetchChains({ + key: refused.model.chains.key ?? "", + // A rule attached since the tab opened now holds the chain. + result: Result.succeed(populatedChains([ruleId(3)])), + }), + ) + expect(reread.model.deleteChainError).toBe( + "Still used by Rule 3. Remove it from its severity tiers first.", + ) // Before the rules list has loaded the rules cannot be named, only counted. const unloaded = pageUpdate( diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index e626941988..6372cb8ef7 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -98,6 +98,8 @@ export const Model = Schema.Struct({ confirmDeleteChainId: Schema.NullOr(EscalationChainId), deletingChainId: Schema.NullOr(EscalationChainId), deleteChainError: Schema.NullOr(Schema.String), + /** The refusal was "in use": its message is rebuilt once the re-read chains land. */ + isDeleteChainInUse: Schema.Boolean, deleteChainDialog: Dialog.Model, /** Silences tab: `alertSilences.list`, read while the tab is open. */ silences: KeyedSchema(Schema.Array(V2AlertSilence), LoadFailure), diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index b56cf14729..7febe0962a 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -224,6 +224,7 @@ export const init = (route: HubRoute, context: PageContext): Return => { confirmDeleteChainId: null, deletingChainId: null, deleteChainError: null, + isDeleteChainInUse: false, deleteChainDialog: Dialog.init(DELETE_CHAIN_DIALOG_ID, "alert-dialog"), silences: Keyed.idle(), endingSilenceIds: [], @@ -420,6 +421,7 @@ const updateDeleteChainDialog = (model: Model, step: ReturnType Message.GotDeleteChainDialogMessage({ message }), @@ -435,7 +437,12 @@ const pickedChainAction = (model: Model, context: PageContext, rawId: string, va onSome: (chain): Return => value === "delete" ? updateDeleteChainDialog( - { ...model, confirmDeleteChainId: chain.id, deleteChainError: null }, + { + ...model, + confirmDeleteChainId: chain.id, + deleteChainError: null, + isDeleteChainInUse: false, + }, Dialog.open(model.deleteChainDialog), ) : liftChainDialog(model, context, ChainDialog.openEdit(model.chainDialog, chain)), @@ -447,7 +454,7 @@ const confirmDeleteChain = (model: Model): Return => { return id === null || model.deletingChainId !== null ? { model } : { - model: { ...model, deletingChainId: id, deleteChainError: null }, + model: { ...model, deletingChainId: id, deleteChainError: null, isDeleteChainInUse: false }, commands: [DeleteChain({ chainId: id })], } } @@ -473,7 +480,14 @@ const deletedChain = ( // again, so a retry names a rule attached since the tab opened. onFailure: (failure): Return => failure.code === CHAIN_IN_USE_CODE - ? refreshChains({ ...settled, deleteChainError: inUseMessage(settled, chainId) }, context) + ? refreshChains( + { + ...settled, + deleteChainError: inUseMessage(settled, chainId), + isDeleteChainInUse: true, + }, + context, + ) : { model: { ...settled, deleteChainError: failure.message } }, }) } @@ -850,9 +864,17 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedFetchSlos: ({ key, result }) => ({ model: { ...model, slos: Keyed.accept(model.slos, key, result) }, }), - CompletedFetchChains: ({ key, result }) => ({ - model: { ...model, chains: Keyed.accept(model.chains, key, result) }, - }), + CompletedFetchChains: ({ key, result }) => { + const next = { ...model, chains: Keyed.accept(model.chains, key, result) } + const refusedId = next.confirmDeleteChainId + // A refusal's message is rebuilt from the chains read after it, naming who holds it now. + return { + model: + next.isDeleteChainInUse && refusedId !== null + ? { ...next, deleteChainError: inUseMessage(next, refusedId) } + : next, + } + }, ClickedRetryChains: () => refreshChains(model, context), ClickedNewChain: () => liftChainDialog(model, context, ChainDialog.openNew(model.chainDialog)), GotChainDialogMessage: ({ message: child }) => From 8b77935b7f31db3c8dd20a9969173d596fdba9e1 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 01:45:43 +0100 Subject: [PATCH 94/98] fix(web): a failing schedules read is retried on the five-minute clock, not every tick --- apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts | 4 ++++ apps/web/src/page/alerts/hub/model.ts | 2 ++ apps/web/src/page/alerts/hub/update.ts | 6 +++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts index a05ccc85f0..fb82dc882b 100644 --- a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts @@ -157,6 +157,10 @@ describe("alerts hub, on-call schedules", () => { given(failed), expectView(role("region", { name: "On call now" })).toExist(), ) + // Not on every tick while it keeps failing: the next try waits the five-minute max age. + expect( + names(pageUpdate(failed, Message.ReadClock({ nowMs: ONCALL_NOW + 4 * hour + 30_000 })).commands), + ).not.toContain("FetchAlertsHubSchedules") expect( names(pageUpdate(failed, Message.ReadClock({ nowMs: ONCALL_NOW + 8 * hour })).commands), ).toContain("FetchAlertsHubSchedules") diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index f0a840d0ef..ff0af0bc6e 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -112,6 +112,8 @@ export const Model = Schema.Struct({ deleteChainDialog: Dialog.Model, /** `oncallSchedules.list`, read on every tab: the inbox strip, the On-call tab and the chain dialog. */ schedules: KeyedSchema(Schema.Array(V2OnCallSchedule), LoadFailure), + /** When the clock last asked for the schedules again; a failing read waits out the max age. */ + schedulesAskedAtMs: Schema.NullOr(Schema.Number), /** The org's members, to name who is on call; `null` until Clerk answers (or failed). */ members: Schema.NullOr(Schema.Array(OrgMember)), /** The members were asked for: only the tabs that name people (inbox, On-call, Routing) ask. */ diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index b88131339c..4bc9ba85eb 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -217,6 +217,9 @@ const isSchedulesStale = (model: Model, nowMs: number): boolean => { const readAt = model.schedules.updatedAtMs // A failed refresh keeps the last list (`Stale`), which still ages and hands off. if (readAt === undefined || Option.isNone(AsyncData.getData(model.schedules.data))) return false + // A re-read asked since the last answer (failing, or in flight) waits the max age, not every tick. + const askedAt = model.schedulesAskedAtMs + if (askedAt !== null && askedAt > readAt && nowMs - askedAt < SCHEDULES_MAX_AGE_MS) return false return ( nowMs - readAt >= SCHEDULES_MAX_AGE_MS || Array.some(schedulesOf(model), (schedule) => Option.exists(handoffMs(schedule), (at) => at <= nowMs)) @@ -227,7 +230,7 @@ const isSchedulesStale = (model: Model, nowMs: number): boolean => { const clockRead = (model: Model, context: PageContext, nowMs: number): Return => { const next = { ...model, nowMs } if (!isSchedulesStale(next, nowMs)) return { model: next } - const step = requestSchedules(next, context, "Refresh") + const step = requestSchedules({ ...next, schedulesAskedAtMs: nowMs }, context, "Refresh") return { model: step.model, commands: step.commands } } @@ -289,6 +292,7 @@ export const init = (route: HubRoute, context: PageContext): Return => { isDeleteChainInUse: false, deleteChainDialog: Dialog.init(DELETE_CHAIN_DIALOG_ID, "alert-dialog"), schedules: Keyed.idle(), + schedulesAskedAtMs: null, members: null, isMembersRequested: false, confirmDeleteScheduleId: null, From c5f493099ab05cf99d26a75c93fc3703c4c9c02a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 03:33:29 +0100 Subject: [PATCH 95/98] fix(web): the schedule header keeps re-reading after a failed refresh, on the five-minute clock; starts before 1970 are refused --- .../web/src/page/alerts/schedule-form/form.ts | 3 +- .../schedule-form/schedule-form.scene.test.ts | 5 +++ apps/web/src/page/alerts/schedule/model.ts | 2 ++ .../alerts/schedule/schedule.scene.test.ts | 34 +++++++++++++++++++ apps/web/src/page/alerts/schedule/update.ts | 16 +++++++-- 5 files changed, 57 insertions(+), 3 deletions(-) diff --git a/apps/web/src/page/alerts/schedule-form/form.ts b/apps/web/src/page/alerts/schedule-form/form.ts index d08691f3a6..dbb603b9ee 100644 --- a/apps/web/src/page/alerts/schedule-form/form.ts +++ b/apps/web/src/page/alerts/schedule-form/form.ts @@ -97,7 +97,8 @@ export const localInputMs = (value: string, timeZone: string): Option.Option= 0 ? Option.some(ms) : Option.none() } /** Today at 09:00 in the zone: where a new layer's rotation starts. */ diff --git a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts index 7cd64e2679..968fa0784c 100644 --- a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts +++ b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts @@ -265,6 +265,11 @@ describe("schedule form", () => { ) }) + test("a start before 1970 is refused, as the API refuses it", () => { + expect(localInputMs("1969-12-31T23:00", "UTC")).toEqual(Option.none()) + expect(localInputMs("1970-01-01T00:00", "UTC")).toEqual(Option.some(0)) + }) + test("a start before year 1000 keeps four year digits", () => { expect(localInputOf(Date.UTC(999, 0, 2, 9, 0), "UTC")).toBe("0999-01-02T09:00") }) diff --git a/apps/web/src/page/alerts/schedule/model.ts b/apps/web/src/page/alerts/schedule/model.ts index e20a3b36c0..545d5434ef 100644 --- a/apps/web/src/page/alerts/schedule/model.ts +++ b/apps/web/src/page/alerts/schedule/model.ts @@ -26,6 +26,8 @@ export const Model = Schema.Struct({ /** The clock, read on entry and by the hub cadence's tick. */ nowMs: Schema.NullOr(Schema.Number), schedule: KeyedSchema(V2OnCallSchedule, LoadFailure), + /** When the clock last asked for the schedule again; a failing read waits out the max age. */ + scheduleAskedAtMs: Schema.NullOr(Schema.Number), /** Weeks from this one the calendar shows: 0 is the week starting today. */ weekOffset: Schema.Number, shifts: KeyedSchema(V2OnCallShifts, LoadFailure), diff --git a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts index c7e17b2472..4866bbee22 100644 --- a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts +++ b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts @@ -220,6 +220,40 @@ describe("on-call schedule page", () => { expect(names(done.commands)).toContain("FetchOnCallSchedule") }) + test("a failed header re-read keeps the last schedule and tries again on the five-minute clock", () => { + const model = loaded(ONCALL_NOW) + const handoff = Option.getOrElse( + Option.flatMap(AsyncData.getData(model.schedule.data), (schedule) => + Option.map(Option.fromNullishOr(schedule.on_call_now), (now) => isoMs(now.ends_at)), + ), + () => 0, + ) + const asked = pageUpdate(model, Message.ReadClock({ nowMs: handoff })) + expect(names(asked.commands)).toContain("FetchOnCallSchedule") + const failed = pageUpdate( + asked.model, + Message.CompletedFetchSchedule({ + key: asked.model.schedule.key ?? "", + readAtMs: handoff, + result: Result.fail({ + _tag: "@maple/http/errors/AlertPersistenceError", + type: "api_error", + code: "persistence_error", + title: "Couldn't read", + message: "Couldn't read", + retryable: true, + recovery: "retry", + }), + }), + ).model + expect( + names(pageUpdate(failed, Message.ReadClock({ nowMs: handoff + 30_000 })).commands), + ).not.toContain("FetchOnCallSchedule") + expect( + names(pageUpdate(failed, Message.ReadClock({ nowMs: handoff + 6 * 60_000 })).commands), + ).toContain("FetchOnCallSchedule") + }) + test("a failed shifts read waits for Try again instead of retrying every clock tick", () => { const model = loaded(ONCALL_NOW) const failed = run(model, [Message.ClickedNextWeek()]) diff --git a/apps/web/src/page/alerts/schedule/update.ts b/apps/web/src/page/alerts/schedule/update.ts index 242d2b50a1..47b0e8f77d 100644 --- a/apps/web/src/page/alerts/schedule/update.ts +++ b/apps/web/src/page/alerts/schedule/update.ts @@ -71,6 +71,7 @@ export const init = (route: ScheduleRoute, context: PageContext): Return => { scheduleId, nowMs: null, schedule: Keyed.idle(), + scheduleAskedAtMs: null, weekOffset: 0, shifts: Keyed.idle(), overrides: Keyed.idle(), @@ -171,7 +172,9 @@ const SCHEDULE_MAX_AGE_MS = 5 * 60_000 /** The header's shift has handed off, or a gap has gone unchecked too long. */ export const isScheduleStale = (model: Model, nowMs: number): boolean => - AsyncData.isSuccess(model.schedule.data) && + // A failed re-read keeps the last schedule (`Stale`), which still ages and hands off. + Option.isSome(AsyncData.getData(model.schedule.data)) && + !isScheduleRetryWaiting(model, nowMs) && Option.exists(scheduleOf(model), (schedule) => Option.match(Option.fromNullishOr(schedule.on_call_now), { onSome: (now) => isoMs(now.ends_at) <= nowMs, @@ -179,10 +182,19 @@ export const isScheduleStale = (model: Model, nowMs: number): boolean => }), ) +/** A re-read asked since the last answer (failing, or in flight) waits the max age, not every tick. */ +const isScheduleRetryWaiting = (model: Model, nowMs: number): boolean => { + const askedAt = model.scheduleAskedAtMs + const readAt = model.schedule.updatedAtMs + return ( + askedAt !== null && readAt !== undefined && askedAt > readAt && nowMs - askedAt < SCHEDULE_MAX_AGE_MS + ) +} + const clockRead = (model: Model, context: PageContext, nowMs: number): Return => { const shifts = requestShifts({ ...model, nowMs }, context, "Load") if (!isScheduleStale(shifts.model, nowMs)) return shifts - const schedule = refreshSchedule(shifts.model, context) + const schedule = refreshSchedule({ ...shifts.model, scheduleAskedAtMs: nowMs }, context) return { model: schedule.model, commands: [...(shifts.commands ?? []), ...(schedule.commands ?? [])] } } From bf8f6fe76665f3c81221c7c8d911b7683c21bf31 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 06:08:30 +0100 Subject: [PATCH 96/98] fix(web): the incident activity follows pages; labs allow only local commands; the strip names its zone; one header over the shift track --- apps/web/src/lab/escalation-lab.ts | 27 +---------- apps/web/src/lab/local-commands.ts | 31 +++++++++++++ apps/web/src/lab/oncall-lab.test.ts | 16 +++++++ apps/web/src/lab/oncall-lab.ts | 6 +-- .../page/alerts/hub/hub-oncall.scene.test.ts | 2 + apps/web/src/page/alerts/hub/view-oncall.ts | 17 ++++--- .../incident-escalation.scene.test.ts | 28 +++++++++++ apps/web/src/page/alerts/incident/update.ts | 46 +++++++++++++++++-- .../views/src/ui/shift-timeline.scene.test.ts | 5 +- packages/views/src/ui/shift-timeline.ts | 4 +- 10 files changed, 137 insertions(+), 45 deletions(-) create mode 100644 apps/web/src/lab/local-commands.ts create mode 100644 apps/web/src/lab/oncall-lab.test.ts diff --git a/apps/web/src/lab/escalation-lab.ts b/apps/web/src/lab/escalation-lab.ts index 6c9ed3de41..91f0462182 100644 --- a/apps/web/src/lab/escalation-lab.ts +++ b/apps/web/src/lab/escalation-lab.ts @@ -5,13 +5,8 @@ import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "eff import { Command, type Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" -import * as Animation from "@foldkit/ui/animation" -import { LoadChartRuntime } from "@maple/views/charts/chart-runtime" -import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" -import * as Popover from "@maple/views/ui/popover" import * as Select from "@maple/views/ui/select" -import * as Tooltip from "@maple/views/ui/tooltip" import * as ChainDialog from "../page/alerts/escalation-dialog/dialog" import * as EditorMessage from "../page/alerts/editor/message" import * as EditorModel from "../page/alerts/editor/model" @@ -36,6 +31,7 @@ import { import { incidentRow, INCIDENT_ID, NOW, ORG, ruleId, ruleRow } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" +import { keepLocal } from "./local-commands" // `/lab/escalation`: escalation chains in the Routing tab, their dialog, a rule tier's chain picker // and the incident rail, over fixtures. `?state=` picks one. @@ -90,27 +86,6 @@ const RULES = [ ruleRow(3, { name: "Payments webhook failures" }), ] -/** The kit's own UI commands and the chart runtime; every page read and write is dropped. */ -const LOCAL_COMMANDS: ReadonlySet = new Set([ - LoadChartRuntime.name, - Animation.WaitForPaint.name, - Animation.WaitForAnimationSettled.name, - Dialog.WaitForDialogTransition.name, - Menu.FocusMenuItem.name, - Menu.WaitSubmenuDelay.name, - Menu.WaitForMenuExit.name, - Select.FocusSelectItem.name, - Select.WaitForSelectExit.name, - Popover.WaitHoverDelay.name, - Popover.WaitForPopoverExit.name, - Tooltip.WaitOpenDelay.name, - Tooltip.CheckFocusVisible.name, -]) - -/** An allowlist, not a denylist: a new API command stays off the real API with fixture ids. */ -const keepLocal = (commands: ReadonlyArray> | undefined) => - Arr.filter(commands ?? [], (command) => LOCAL_COMMANDS.has(command.name)) - /** Runs messages through a page's update, keeping the local commands (a dialog's open animation). */ const fold = ( start: Step, diff --git a/apps/web/src/lab/local-commands.ts b/apps/web/src/lab/local-commands.ts new file mode 100644 index 0000000000..3f8bbcf7f2 --- /dev/null +++ b/apps/web/src/lab/local-commands.ts @@ -0,0 +1,31 @@ +import { Array as Arr } from "effect" +import type { Command } from "foldkit" +import * as Animation from "@foldkit/ui/animation" +import { LoadChartRuntime } from "@maple/views/charts/chart-runtime" +import * as Dialog from "@maple/views/ui/dialog" +import * as Menu from "@maple/views/ui/menu" +import * as Popover from "@maple/views/ui/popover" +import * as Select from "@maple/views/ui/select" +import * as Tooltip from "@maple/views/ui/tooltip" +import type { Resources } from "../platform/resources" + +/** The kit's own UI commands and the chart runtime; every page read and write is dropped. */ +const LOCAL_COMMANDS: ReadonlySet = new Set([ + LoadChartRuntime.name, + Animation.WaitForPaint.name, + Animation.WaitForAnimationSettled.name, + Dialog.WaitForDialogTransition.name, + Menu.FocusMenuItem.name, + Menu.WaitSubmenuDelay.name, + Menu.WaitForMenuExit.name, + Select.FocusSelectItem.name, + Select.WaitForSelectExit.name, + Popover.WaitHoverDelay.name, + Popover.WaitForPopoverExit.name, + Tooltip.WaitOpenDelay.name, + Tooltip.CheckFocusVisible.name, +]) + +/** An allowlist, not a denylist: a new API command stays off the real API with fixture ids. */ +export const keepLocal = (commands: ReadonlyArray> | undefined) => + Arr.filter(commands ?? [], (command) => LOCAL_COMMANDS.has(command.name)) diff --git a/apps/web/src/lab/oncall-lab.test.ts b/apps/web/src/lab/oncall-lab.test.ts new file mode 100644 index 0000000000..bf5a885358 --- /dev/null +++ b/apps/web/src/lab/oncall-lab.test.ts @@ -0,0 +1,16 @@ +import { Array as Arr } from "effect" +import { describe, expect, it } from "vitest" +import * as IncidentMessage from "../page/alerts/incident/message" +import { init, Message, update } from "./oncall-lab" + +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) + +describe("on-call lab", () => { + it("drops page writes so fixture ids never reach the API", () => { + const { model } = init({ state: "incident-paged" }) + const incident = (message: IncidentMessage.Message) => + names(update(model, Message.GotIncidentMessage({ message })).commands) + expect(incident(IncidentMessage.Message.ClickedAcknowledge({ acknowledged: true }))).toEqual([]) + }) +}) diff --git a/apps/web/src/lab/oncall-lab.ts b/apps/web/src/lab/oncall-lab.ts index cdfbaec4dd..c5e488aba3 100644 --- a/apps/web/src/lab/oncall-lab.ts +++ b/apps/web/src/lab/oncall-lab.ts @@ -8,6 +8,7 @@ import { Command, type Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" import * as Menu from "@maple/views/ui/menu" +import { keepLocal } from "./local-commands" import * as HubMessage from "../page/alerts/hub/message" import * as HubModel from "../page/alerts/hub/model" import * as HubUpdate from "../page/alerts/hub/update" @@ -100,11 +101,6 @@ const CONTEXT: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNames const GRANTS = Policy.grantsOf(["org:engineer"]) /** Reads and writes are dropped: the fixture is all the data there is. */ -const keepLocal = (commands: ReadonlyArray> | undefined) => - Arr.filter( - commands ?? [], - (command) => !/^(Fetch|Toggle|Delete|Save|Read|Create|Generate)/.test(command.name), - ) const fold = ( start: Step, diff --git a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts index fb82dc882b..4c619a587c 100644 --- a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts @@ -111,6 +111,8 @@ describe("alerts hub, on-call schedules", () => { expectView(role("region", { name: "On call now" })).toExist(), expectView(text("Sofia Alvarez")).toExist(), expectView(text("Priya Raman")).toExist(), + // The handoff reads on the schedule's clock, so the strip names the zone. + expectView(text(/^until .+ Europe\/Berlin$/u)).toExist(), ) scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/alerts/hub/view-oncall.ts b/apps/web/src/page/alerts/hub/view-oncall.ts index 022a8f8dbd..a27509f16b 100644 --- a/apps/web/src/page/alerts/hub/view-oncall.ts +++ b/apps/web/src/page/alerts/hub/view-oncall.ts @@ -33,13 +33,18 @@ import { isoMs } from "../shared/time" type H = HtmlBuilder -/** Who holds the schedule now, with when they hand off; "Nobody on call" in a gap. */ -const onCallPerson = (h: H, model: Model, schedule: V2OnCallSchedule): Html => +/** + * Who holds the schedule now, with when they hand off; "Nobody on call" in a gap. The handoff + * reads on the schedule's clock, so `withZone` names it where the zone is not shown beside it. + */ +const onCallPerson = (h: H, model: Model, schedule: V2OnCallSchedule, withZone: boolean): Html => Option.match(Option.fromNullishOr(schedule.on_call_now), { onNone: () => hstack(h, { gap: "2" }, [ icon(h, "UserIcon", { className: "text-foreground-subtle" }), - typography(h, { size: "sm", tone: "muted", className: "whitespace-nowrap" }, ["Nobody on call"]), + typography(h, { size: "sm", tone: "muted", className: "whitespace-nowrap" }, [ + "Nobody on call", + ]), ]), onSome: (now) => personLabel(h, { @@ -47,7 +52,7 @@ const onCallPerson = (h: H, model: Model, schedule: V2OnCallSchedule): Html => imageUrl: Option.getOrUndefined( Option.map(memberOf(model.members, now.user_id), (member) => member.imageUrl), ), - detail: `until ${formatShiftEdge(isoMs(now.ends_at), schedule.time_zone)}`, + detail: `until ${formatShiftEdge(isoMs(now.ends_at), schedule.time_zone)}${withZone ? ` ${schedule.time_zone}` : ""}`, }), }) @@ -101,7 +106,7 @@ const scheduleRow = (h: H, model: Model, schedule: V2OnCallSchedule, nowMs: numb hstack(h, { gap: "4", className: "w-full pl-7 sm:w-auto sm:pl-0" }, [ h.div( [h.Class("min-w-0 flex-1 sm:w-56 sm:flex-none")], - [onCallPerson(h, model, schedule)], + [onCallPerson(h, model, schedule, false)], ), typography( h, @@ -217,7 +222,7 @@ const stripItem = (h: H, model: Model, schedule: V2OnCallSchedule): Html => textLink(h, { href: scheduleHref(schedule.id), tone: "muted", className: "min-w-0" }, [ typography(h, { size: "xs", truncate: true }, [schedule.name]), ]), - onCallPerson(h, model, schedule), + onCallPerson(h, model, schedule, true), ], ) diff --git a/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts b/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts index bd7393322c..467d947a11 100644 --- a/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts +++ b/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts @@ -7,6 +7,7 @@ import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colo import { toLoadFailure } from "@maple/views/data/load-failure" import type { PageContext } from "../../contract" import { runOf } from "../shared/escalation-test-fixtures" +import { pagedEvents } from "../shared/oncall-test-fixtures" import { incidentRow, INCIDENT_ID, NOW, ORG, ruleRow } from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" @@ -72,6 +73,33 @@ describe("alert incident, escalation", () => { ).not.toContain("FetchAlertIncidentEscalation") }) + test("the activity is read again when a step pages people, and while a page is on its way", () => { + const loaded = synced() + const read = pageUpdate( + loaded, + Message.CompletedFetchActivity({ key: loaded.activity.key ?? "", result: Result.succeed([]) }), + ).model + const running = withRun(read, runOf("active", { stepsSent: 1 })) + const stepped = pageUpdate( + running, + Message.CompletedFetchEscalation({ + key: running.escalation.key ?? "", + result: Result.succeed(runOf("active", { stepsSent: 2 })), + }), + ) + expect(names(stepped.commands)).toContain("FetchAlertIncidentActivity") + const sending = pageUpdate( + stepped.model, + Message.CompletedFetchActivity({ + key: stepped.model.activity.key ?? "", + result: Result.succeed(pagedEvents(INCIDENT_ID, NOW)), + }), + ).model + expect(names(pageUpdate(sending, Message.TickedClock({ nowMs: NOW + 60_000 })).commands)).toContain( + "FetchAlertIncidentActivity", + ) + }) + test("a running chain shows its step and when it pages next", () => { scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/alerts/incident/update.ts b/apps/web/src/page/alerts/incident/update.ts index 0b0947accf..e02a2dd8bf 100644 --- a/apps/web/src/page/alerts/incident/update.ts +++ b/apps/web/src/page/alerts/incident/update.ts @@ -509,10 +509,42 @@ const refreshDueEscalation = (model: Model, nowMs: number): Return => { }) } +/** Re-reads the activity on its own key: pages are written without moving anything that keys it. */ +const refreshActivity = (model: Model): Return => { + const key = model.activity.key + const view = incidentViewOf(model) + if (key === null || Option.isNone(view)) return { model } + const incidentId = view.value.incident.id + return Option.match(Keyed.request(model.activity, key, "Refresh"), { + onNone: () => ({ model }), + onSome: (activity): Return => ({ + model: { ...model, activity }, + commands: [FetchIncidentActivity({ key: activity.key, incidentId })], + }), + }) +} + +const isPageInFlight = (model: Model): boolean => + Arr.some( + Option.getOrElse(AsyncData.getData(model.activity.data), () => []), + (event) => event.page?.status === "queued" || event.page?.status === "sending", + ) + +const stepsSentOf = (model: Model): number | null => + Option.getOrNull(AsyncData.getData(model.escalation.data))?.steps_sent ?? null + +/** The clock re-reads a due step, and the activity while a page is still on its way. */ +const tick = (model: Model, nowMs: number): Return => { + const escalation = refreshDueEscalation(model, nowMs) + if (!isPageInFlight(escalation.model)) return escalation + const activity = refreshActivity(escalation.model) + return { model: activity.model, commands: [...(escalation.commands ?? []), ...(activity.commands ?? [])] } +} + 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 }) => refreshDueEscalation({ ...model, clockMs: nowMs }, nowMs), + TickedClock: ({ nowMs }) => tick({ ...model, clockMs: nowMs }, 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 }), @@ -566,9 +598,15 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedFetchInvestigation: ({ key, result }) => ({ model: { ...model, investigation: Keyed.accept(model.investigation, key, result) }, }), - CompletedFetchEscalation: ({ key, result }) => ({ - model: { ...model, escalation: Keyed.accept(model.escalation, key, result) }, - }), + CompletedFetchEscalation: ({ key, result }) => { + const next = { ...model, escalation: Keyed.accept(model.escalation, key, result) } + // A step that paged people shows in the activity, which no synced row re-keys. + const before = stepsSentOf(model) + const after = stepsSentOf(next) + return before !== null && after !== null && after !== before + ? refreshActivity(next) + : { model: next } + }, CompletedFetchSlos: ({ key, result }) => ({ model: { ...model, slos: Keyed.accept(model.slos, key, result) }, }), diff --git a/packages/views/src/ui/shift-timeline.scene.test.ts b/packages/views/src/ui/shift-timeline.scene.test.ts index 0fa862edbf..0181893586 100644 --- a/packages/views/src/ui/shift-timeline.scene.test.ts +++ b/packages/views/src/ui/shift-timeline.scene.test.ts @@ -69,11 +69,12 @@ const view = (model: Model, h: HtmlBuilder): Html => const update = (model: Model) => ({ model, commands: [] }) describe("shift timeline", () => { - test("reads as a table: day column headers, a row header per lane, and every bar named", () => { + test("reads as a table: one header over the track, a row header per lane, and every bar named", () => { scene( { update, view }, given({ nowMs: FROM + 35 * HOUR }), - expectView(role("columnheader", { name: "Mon 9" })).toExist(), + expectView(role("columnheader", { name: "Shifts" })).toExist(), + expectView(text("Mon 9")).toExist(), expectView(role("rowheader", { name: /Primary/u })).toExist(), expectView(role("button", { name: "Ana, 1 hour" })).toExist(), expectView(role("img", { name: "Ben, Mon" })).toExist(), diff --git a/packages/views/src/ui/shift-timeline.ts b/packages/views/src/ui/shift-timeline.ts index ebbd1c7279..38bfa4b949 100644 --- a/packages/views/src/ui/shift-timeline.ts +++ b/packages/views/src/ui/shift-timeline.ts @@ -239,13 +239,13 @@ export const shiftTimeline = ( [h.Class("flex border-b"), h.Role("row")], [ h.div([h.Class(LABEL_COLUMN), h.Role("columnheader")], [h.span([h.Class("sr-only")], ["Lane"])]), + // One header for the track, as each row has one cell for it; the days label it visually. h.div( - [h.Class("relative h-8 grow")], + [h.Class("relative h-8 grow"), h.Role("columnheader"), h.AriaLabel("Shifts")], Arr.map(days, (day) => h.div( [ h.Key(`day-${day.startMs}`), - h.Role("columnheader"), h.Class( "absolute inset-y-0 overflow-hidden border-l border-border-subtle px-1.5 py-1.5", ), From b9733a874135e6510cdc45c501c6ae1b8ea6708b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 08:29:07 +0100 Subject: [PATCH 97/98] fix(web): overrides follow every page; the activity re-read supersedes one in flight; rotation and days controls name their layer --- apps/web/src/page/alerts/incident/update.ts | 3 +- .../schedule-form/schedule-form.scene.test.ts | 2 ++ .../web/src/page/alerts/schedule-form/view.ts | 18 ++++++---- .../page/alerts/shared/oncall-schedules.ts | 34 +++++++++++++++---- 4 files changed, 44 insertions(+), 13 deletions(-) diff --git a/apps/web/src/page/alerts/incident/update.ts b/apps/web/src/page/alerts/incident/update.ts index e02a2dd8bf..eb2b7ade82 100644 --- a/apps/web/src/page/alerts/incident/update.ts +++ b/apps/web/src/page/alerts/incident/update.ts @@ -515,7 +515,8 @@ const refreshActivity = (model: Model): Return => { const view = incidentViewOf(model) if (key === null || Option.isNone(view)) return { model } const incidentId = view.value.incident.id - return Option.match(Keyed.request(model.activity, key, "Refresh"), { + // Supersede: a read already in flight may predate the page, so it must not swallow this one. + return Option.match(Keyed.requestFor(model.activity, key, "Supersede"), { onNone: () => ({ model }), onSome: (activity): Return => ({ model: { ...model, activity }, diff --git a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts index 968fa0784c..f290843e39 100644 --- a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts +++ b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts @@ -261,6 +261,8 @@ describe("schedule form", () => { given(model), expectView(text("Runs 24 hours, to the same time the next day")).toExist(), expectView(role("button", { name: "Remove this window from layer 1" })).toExist(), + expectView(role("group", { name: "Days of window 1 in layer 1" })).toExist(), + expectView(role("group", { name: "Rotation in layer 1" })).toExist(), expectView(text("Ends the next morning")).not.toExist(), ) }) diff --git a/apps/web/src/page/alerts/schedule-form/view.ts b/apps/web/src/page/alerts/schedule-form/view.ts index 3747aac0e4..3aca283c63 100644 --- a/apps/web/src/page/alerts/schedule-form/view.ts +++ b/apps/web/src/page/alerts/schedule-form/view.ts @@ -231,12 +231,12 @@ const layerText = (key: string, field: LayerField) => (value: string) => Message.ChangedLayerText({ key, field, value }) /** How the layer hands off: the kind, then its day and time or its shift length. */ -const rotationField = (h: H, layer: LayerDraft): Html => { +const rotationField = (h: H, layer: LayerDraft, index: number): Html => { const key = layer.key return vstack(h, { gap: "2" }, [ typography(h, { size: "xs", tone: "muted", as: "p" }, ["Hand off"]), segmentedSelect(h, { - ariaLabel: "Rotation", + ariaLabel: `Rotation in layer ${index + 1}`, size: "sm", className: "w-fit", value: layer.rotation, @@ -306,7 +306,13 @@ const rotationField = (h: H, layer: LayerDraft): Html => { } /** One weekly window: its days, from and to (an end at or before the start crosses midnight). */ -const restrictionRow = (h: H, layer: LayerDraft, layerIndex: number, restriction: RestrictionDraft): Html => { +const restrictionRow = ( + h: H, + layer: LayerDraft, + layerIndex: number, + windowIndex: number, + restriction: RestrictionDraft, +): Html => { const key = layer.key const restrictionKey = restriction.key const isComplete = isLocalTime(restriction.startTime) && isLocalTime(restriction.endTime) @@ -320,7 +326,7 @@ const restrictionRow = (h: H, layer: LayerDraft, layerIndex: number, restriction return vstack(h, { gap: "2", className: "rounded-md border p-3", attributes: [h.Key(restrictionKey)] }, [ hstack(h, { gap: "2", justify: "between", wrap: true }, [ multiSegmentedSelect(h, { - ariaLabel: "Days", + ariaLabel: `Days of window ${windowIndex + 1} in layer ${layerIndex + 1}`, size: "sm", className: "flex-wrap", value: restriction.weekdays, @@ -377,7 +383,7 @@ const restrictionsField = (h: H, model: Model, layer: LayerDraft, layerIndex: nu : "Only active in these windows", ]), ...Arr.flatMap(layer.restrictions, (restriction, index) => [ - restrictionRow(h, layer, layerIndex, restriction), + restrictionRow(h, layer, layerIndex, index, restriction), ...shown(h, model, errors[index] ?? Option.none()), ]), button( @@ -451,7 +457,7 @@ const layerBlock = (h: H, model: Model, layer: LayerDraft, index: number): Html ...shown(h, model, errors.name), participantsField(h, model, layer, index), ...shown(h, model, errors.participants), - rotationField(h, layer), + rotationField(h, layer, index), ...shown(h, model, errors.handoff), textInput(h, { id: `${key}-starts`, diff --git a/apps/web/src/page/alerts/shared/oncall-schedules.ts b/apps/web/src/page/alerts/shared/oncall-schedules.ts index 5f0811aa27..0d2b0dd2fd 100644 --- a/apps/web/src/page/alerts/shared/oncall-schedules.ts +++ b/apps/web/src/page/alerts/shared/oncall-schedules.ts @@ -1,12 +1,13 @@ -import type { OnCallOverrideId, OnCallScheduleId } from "@maple/domain/http" +import { MAX_ONCALL_FUTURE_OVERRIDES, type OnCallOverrideId, type OnCallScheduleId } from "@maple/domain/http" import { OnCallSchedulePublicId, + type V2OnCallOverride, type V2OnCallOverrideCreateParams, type V2OnCallSchedule, type V2OnCallScheduleCreateParams, } from "@maple/domain/http/v2" import { Effect, Option, Schema } from "effect" -import { toLoadFailure } from "@maple/views/data/load-failure" +import { type LoadFailure, toLoadFailure } from "@maple/views/data/load-failure" import { MapleApiV2Client } from "../../../platform/api-clients" import { AppRoute, hrefOf } from "../../../route" import type { AlertsScheduleNewSearch } from "../../../route/search" @@ -64,13 +65,34 @@ export const listShifts = (id: OnCallScheduleId, fromMs: number, toMs: number) = }) }).pipe(Effect.mapError(toLoadFailure)) -/** The overrides that have not ended yet, by start. */ +const OVERRIDES_PAGE_SIZE = 100 + +/** The overrides that have not ended yet, by start: every page (a schedule holds up to 500). */ export const listOverrides = (id: OnCallScheduleId) => Effect.gen(function* () { const client = yield* MapleApiV2Client - const page = yield* client.oncallSchedules.listOverrides({ params: { id }, query: { limit: 100 } }) - return page.data - }).pipe(Effect.mapError(toLoadFailure)) + const pages = ( + cursor: string | undefined, + read: ReadonlyArray, + ): Effect.Effect, LoadFailure> => + client.oncallSchedules + .listOverrides({ + params: { id }, + query: { limit: OVERRIDES_PAGE_SIZE, ...(cursor === undefined ? undefined : { cursor }) }, + }) + .pipe( + Effect.mapError(toLoadFailure), + Effect.flatMap((page) => { + const all = [...read, ...page.data] + return page.has_more && + page.next_cursor !== null && + all.length < MAX_ONCALL_FUTURE_OVERRIDES + ? pages(page.next_cursor, all) + : Effect.succeed(all) + }), + ) + return yield* pages(undefined, []) + }) export const createOverride = (id: OnCallScheduleId, payload: V2OnCallOverrideCreateParams) => Effect.gen(function* () { From e0f748155abf2b236f8777bcc2bd433aef966b35 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 09:32:59 +0100 Subject: [PATCH 98/98] fix(web): the activity poll refreshes, only a new step supersedes a read in flight --- apps/web/src/page/alerts/incident/update.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/web/src/page/alerts/incident/update.ts b/apps/web/src/page/alerts/incident/update.ts index eb2b7ade82..eb1c7208f4 100644 --- a/apps/web/src/page/alerts/incident/update.ts +++ b/apps/web/src/page/alerts/incident/update.ts @@ -510,13 +510,14 @@ const refreshDueEscalation = (model: Model, nowMs: number): Return => { } /** Re-reads the activity on its own key: pages are written without moving anything that keys it. */ -const refreshActivity = (model: Model): Return => { +const refreshActivity = (model: Model, mode: "Refresh" | "Supersede"): Return => { const key = model.activity.key const view = incidentViewOf(model) if (key === null || Option.isNone(view)) return { model } const incidentId = view.value.incident.id - // Supersede: a read already in flight may predate the page, so it must not swallow this one. - return Option.match(Keyed.requestFor(model.activity, key, "Supersede"), { + // A new step supersedes a read in flight, which may predate its page; the poll only refreshes, + // so a slow read is not discarded by the next tick. + return Option.match(Keyed.requestFor(model.activity, key, mode), { onNone: () => ({ model }), onSome: (activity): Return => ({ model: { ...model, activity }, @@ -538,7 +539,7 @@ const stepsSentOf = (model: Model): number | null => const tick = (model: Model, nowMs: number): Return => { const escalation = refreshDueEscalation(model, nowMs) if (!isPageInFlight(escalation.model)) return escalation - const activity = refreshActivity(escalation.model) + const activity = refreshActivity(escalation.model, "Refresh") return { model: activity.model, commands: [...(escalation.commands ?? []), ...(activity.commands ?? [])] } } @@ -605,7 +606,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re const before = stepsSentOf(model) const after = stepsSentOf(next) return before !== null && after !== null && after !== before - ? refreshActivity(next) + ? refreshActivity(next, "Supersede") : { model: next } }, CompletedFetchSlos: ({ key, result }) => ({