diff --git a/apps/ai/src/mcp/dispatcher.ts b/apps/ai/src/mcp/dispatcher.ts index b1449db8c8..adf0bb6e92 100644 --- a/apps/ai/src/mcp/dispatcher.ts +++ b/apps/ai/src/mcp/dispatcher.ts @@ -11,8 +11,10 @@ import { AlertsService } from "@maple/backend/services/alerts/AlertsService" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" +import { AlertIncidentPagesService } from "@maple/backend/services/alerts/AlertIncidentPagesService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" import { EscalationChainsService } from "@maple/backend/services/alerts/EscalationChainsService" +import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" import { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService" @@ -274,8 +276,10 @@ const McpRuntimeServicesLive = Layer.mergeAll( AgentFeedbackService.layer, AlertReadModelsService.layer, AlertIncidentAckService.layer, + AlertIncidentPagesService.layer, AlertRulesService.layer, EscalationChainsService.layer, + OnCallSchedulesService.layer, AlertSilencesService.layer, AlertsService.layer, AuditLogService.layer, diff --git a/apps/ai/src/mcp/lib/escalation-chains.ts b/apps/ai/src/mcp/lib/escalation-chains.ts index 0a24b89704..65750b2498 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, SchemaIssue } from "effect" -import type { - AlertDestinationDocument, - AlertForbiddenError, - OrgId, - AlertPersistenceError, - AlertValidationError, - EscalationChainDocument, - EscalationChainInUseError, - EscalationChainNotFoundError, +import { + UserId, + type AlertDestinationDocument, + type AlertForbiddenError, + type AlertMemberDirectoryUnavailableError, + type OrgId, + type AlertPersistenceError, + type AlertValidationError, + type EscalationChainDocument, + type EscalationChainInUseError, + type EscalationChainNotFoundError, + type EscalationTarget, } from "@maple/domain/http" import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" import type { EscalationChainRow } from "@maple/domain/mcp-outputs" import { AlertsService } from "@maple/backend/services/alerts/AlertsService" +import { OnCallSchedulesService } from "@maple/backend/services/alerts/OnCallSchedulesService" import { McpInvalidInputError } from "../tools/types" import { toMcpHttpError } from "./map-http-error" import * as P from "./params" @@ -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,50 +59,120 @@ 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' + +const decodeUserId = Schema.decodeUnknownEffect(UserId) -/** The steps as the service schema takes them, destination ids decoded to internal ids. */ +/** The steps as the service schema takes them, ids decoded to internal ids. */ export const toStepsRequest = (steps: typeof StepsInput.Type) => Effect.forEach(steps, (step) => - Effect.map( - Effect.forEach(step.destination_ids, P.decodeResourceId("alertDestination", "steps")), - (ids) => ({ - targets: Arr.map(ids, (destinationId) => ({ type: "destination" as const, destinationId })), - waitMinutes: step.wait_minutes, - }), - ), + Effect.gen(function* () { + const destinationIds = yield* Effect.forEach( + step.destination_ids ?? [], + P.decodeResourceId("alertDestination", "steps"), + ) + const userIds = yield* Effect.forEach(step.user_ids ?? [], (raw) => + decodeUserId(raw).pipe( + Effect.mapError( + () => + new McpInvalidInputError({ + message: `Not a user id: ${raw}`, + parameter: "steps", + }), + ), + ), + ) + const scheduleIds = yield* Effect.forEach( + step.schedule_ids ?? [], + P.decodeResourceId("oncallSchedule", "steps"), + ) + const targets: ReadonlyArray = [ + ...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, + publicId: encodePublicId(PublicIdPrefixes.alertDestination, target.destinationId), + name: known?.name ?? null, + type: known?.type ?? null, + } + } + case "user": + return { + kind: "user", + id: target.userId, + publicId: target.userId, + name: directory.members.get(target.userId) ?? null, + type: null, + } + case "schedule": + return { + kind: "schedule", + id: target.scheduleId, + publicId: encodePublicId(PublicIdPrefixes.oncallSchedule, target.scheduleId), + name: Option.getOrNull( + Option.map( + Arr.findFirst(directory.schedules, (s) => s.id === target.scheduleId), + (s) => s.name, + ), + ), + type: null, + } + } +} + export const toEscalationChainRow = ( chain: EscalationChainDocument, - destinations: ReadonlyArray, + directory: TargetDirectory, ): ChainRow => ({ id: chain.id, publicId: encodePublicId(PublicIdPrefixes.escalationChain, chain.id), name: chain.name, description: chain.description, steps: Arr.map(chain.steps, (step) => ({ - targets: Arr.map(step.targets, (target) => { - const known = Option.getOrUndefined( - Arr.findFirst(destinations, (d) => d.id === target.destinationId), - ) - return { - destinationId: target.destinationId, - destinationPublicId: encodePublicId(PublicIdPrefixes.alertDestination, target.destinationId), - name: known?.name ?? null, - type: known?.type ?? null, - } - }), + targets: Arr.map(step.targets, (target) => toTargetRow(target, directory)), waitMinutes: step.waitMinutes, })), repeatCount: chain.repeatCount, @@ -93,14 +188,22 @@ const formatWait = (minutes: number): string => ? `${minutes / 60}h` : `${Math.floor(minutes / 60)}h ${minutes % 60}m` -const targetLabel = (target: ChainRow["steps"][number]["targets"][number]): string => - target.name === null - ? `destination ${target.destinationPublicId}` - : target.type === null - ? `${target.name} (${target.destinationPublicId})` - : `${target.name} (${target.type}, ${target.destinationPublicId})` +const targetLabel = (target: ChainRow["steps"][number]["targets"][number]): string => { + switch (target.kind) { + case "destination": + return target.name === null + ? `destination ${target.publicId}` + : target.type === null + ? `${target.name} (${target.publicId})` + : `${target.name} (${target.type}, ${target.publicId})` + case "user": + return target.name === null ? `user ${target.publicId}` : `${target.name} (${target.publicId})` + case "schedule": + return `whoever is on call on ${target.name === null ? `schedule ${target.publicId}` : `${target.name} (${target.publicId})`}` + } +} -/** One line per step, e.g. `1. notify On-call (pagerduty, dest_…), then wait 10m for an ack`. */ +/** One line per step, e.g. `1. notify On-call (pagerduty, dest_…), Ana (user_…), then wait 10m for an ack`. */ const formatSteps = (chain: ChainRow): string => Arr.join( Arr.map( @@ -142,8 +245,6 @@ const toolKey = (segment: PropertyKey): string => { return "repeat_count" case "waitMinutes": return "wait_minutes" - case "targets": - return "destination_ids" default: return key } @@ -152,9 +253,12 @@ const toolKey = (segment: PropertyKey): string => { /** A request that failed to decode, reported against the parameter its first issue is under. */ export const invalidChainRequest = (error: Schema.SchemaError): McpInvalidInputError => { const first = formatIssues(error.issue).issues[0] - const path = Arr.map(first?.path ?? [], (segment) => + const issuePath = Arr.map(first?.path ?? [], (segment) => typeof segment === "object" && segment !== null ? segment.key : segment, ) + // A step's targets come from three id lists, so a targets issue is reported on the step. + const targetsAt = Arr.findFirstIndex(issuePath, (segment) => segment === "targets") + const path = Option.match(targetsAt, { onNone: () => issuePath, onSome: (at) => issuePath.slice(0, at) }) const [root] = path const location = Arr.join( Arr.map(path, (segment, index) => @@ -162,15 +266,24 @@ export const invalidChainRequest = (error: Schema.SchemaError): McpInvalidInputE ), "", ) - const message = first?.message ?? String(error) + const issueMessage = first?.message ?? String(error) + const message = Option.match(targetsAt, { + onNone: () => issueMessage, + onSome: (at) => + at === issuePath.length - 1 + ? `destination_ids, user_ids and schedule_ids together: ${issueMessage}` + : "needs at least one id in destination_ids, user_ids or schedule_ids", + }) return new McpInvalidInputError({ message: location === "" ? `Invalid escalation chain: ${message}` : `${location}: ${message}`, parameter: root === undefined ? "steps" : toolKey(root), }) } -const failWith = (pipeName: string) => (error: AlertForbiddenError | AlertPersistenceError) => - Effect.fail(toMcpHttpError(pipeName)(error)) +const failWith = + (pipeName: string) => + (error: AlertForbiddenError | AlertPersistenceError | AlertMemberDirectoryUnavailableError) => + Effect.fail(toMcpHttpError(pipeName)(error)) const invalidChain = (error: AlertValidationError) => Effect.fail( @@ -184,6 +297,7 @@ export const createChainErrors = { "@maple/http/errors/AlertValidationError": invalidChain, "@maple/http/errors/AlertForbiddenError": failWith("create_escalation_chain"), "@maple/http/errors/AlertPersistenceError": failWith("create_escalation_chain"), + "@maple/http/errors/AlertMemberDirectoryUnavailableError": failWith("create_escalation_chain"), } export const updateChainErrors = { @@ -192,6 +306,7 @@ export const updateChainErrors = { Effect.fail(chainNotFound(error.chainId)), "@maple/http/errors/AlertForbiddenError": failWith("update_escalation_chain"), "@maple/http/errors/AlertPersistenceError": failWith("update_escalation_chain"), + "@maple/http/errors/AlertMemberDirectoryUnavailableError": failWith("update_escalation_chain"), } export const deleteChainErrors = { 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..1a2c7db9cd --- /dev/null +++ b/apps/ai/src/mcp/lib/oncall-schedules.ts @@ -0,0 +1,262 @@ +/** 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, + OnCallScheduleInUseError, + 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/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, +} +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); 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) + 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(toolName), + }), + ) + } + 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), + ).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 bb9369b756..2c579659b8 100644 --- a/apps/ai/src/mcp/lib/params.ts +++ b/apps/ai/src/mcp/lib/params.ts @@ -26,6 +26,8 @@ import { DashboardId, ErrorIssueId, EscalationChainId, + OnCallOverrideId, + OnCallScheduleId, } from "@maple/domain/primitives" import { WarehouseTimeInput, type WarehouseDateTime } from "@maple/query-engine" import { McpInvalidInputError } from "../tools/types" @@ -164,6 +166,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", + }, alertSilence: { prefix: PublicIdPrefixes.alertSilence, schema: AlertSilenceId, noun: "alert silence" }, dashboard: { prefix: PublicIdPrefixes.dashboard, schema: DashboardId, noun: "dashboard" }, errorIssue: { prefix: PublicIdPrefixes.errorIssue, schema: ErrorIssueId, noun: "error issue" }, diff --git a/apps/ai/src/mcp/resources/instructions.ts b/apps/ai/src/mcp/resources/instructions.ts index 68d4638925..cda292b266 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. - Quieting alerts for a window (maintenance, a known outage): create_alert_silence (matchers + duration_minutes or ends_at) / list_alert_silences / end_alert_silence. Incidents still open; only notifications are held back. - Uptime: list_uptime_monitors is every http/tcp/dns check and heartbeat with its status and last check; a failed check's trace id goes to inspect_trace. Admins manage them with create_uptime_monitor / update_uptime_monitor (enabled=false pauses) / delete_uptime_monitor. A down monitor opens an alert incident with signal type \`uptime\`; its rule is managed only through the monitor. - SLOs: list_slos is every SLO with its SLI, budget left and worst burn rate, burning ones first -> inspect_slo (each 5m/30m/1h/6h burn window, which tier breaches, alerting) -> get_incident_timeline rule_id=. 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. diff --git a/apps/ai/src/mcp/tool-descriptors.generated.ts b/apps/ai/src/mcp/tool-descriptors.generated.ts index d390146550..f43e67d86c 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"}]},"anomalyIncidentId":{"anyOf":[{"type":"string"},{"type":"null"}]},"groupLabel":{"anyOf":[{"type":"string"},{"type":"null"}]},"labels":{"anyOf":[{"type":"object","properties":{"service":{"type":"string"},"environment":{"type":"string"}},"additionalProperties":true},{"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","anomaly"],"description":"What the rule measures. builder_query takes query_builder_draft, raw_query takes raw_query_sql, anomaly pages on the anomaly detector's incidents (see anomaly_signals). 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."},"anomaly_signals":{"type":"array","items":{"type":"string","enum":["error_rate","latency_p95","throughput","error_spike","log_volume"]},"description":"signal_type=anomaly only: detector signals the rule pages on (empty or omitted: every signal). An anomaly rule takes no comparator, threshold or window: it opens when the anomaly detector opens an anomaly in its scope (services, environments, anomaly_signals) at or above its severity, and resolves when that anomaly does."},"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"}]},"groupLabel":{"anyOf":[{"type":"string"},{"type":"null"}]},"labels":{"anyOf":[{"type":"object","properties":{"service":{"type":"string"},"environment":{"type":"string"}},"additionalProperties":true},{"type":"null"}]},"signalType":{"type":"string"},"severity":{"type":"string"},"status":{"type":"string"},"comparator":{"type":"string"},"threshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"lastObservedValue":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"firstTriggeredAt":{"type":"string"},"lastTriggeredAt":{"type":"string"},"resolvedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastNotifiedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"silenceId":{"anyOf":[{"type":"string"},{"type":"null"}]},"silencedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"silenceReason":{"type":"string"},"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"}]},"groupLabel":{"anyOf":[{"type":"string"},{"type":"null"}]},"labels":{"anyOf":[{"type":"object","properties":{"service":{"type":"string"},"environment":{"type":"string"}},"additionalProperties":true},{"type":"null"}]},"signalType":{"type":"string"},"severity":{"type":"string"},"status":{"type":"string"},"comparator":{"type":"string"},"threshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"lastObservedValue":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"firstTriggeredAt":{"type":"string"},"lastTriggeredAt":{"type":"string"},"resolvedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastNotifiedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"silenceId":{"anyOf":[{"type":"string"},{"type":"null"}]},"silencedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"silenceReason":{"type":"string"},"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","anomaly"],"description":"What the rule measures. builder_query takes query_builder_draft, raw_query takes raw_query_sql, anomaly pages on the anomaly detector's incidents (see anomaly_signals). 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."},"anomaly_signals":{"type":"array","items":{"type":"string","enum":["error_rate","latency_p95","throughput","error_spike","log_volume"]},"description":"signal_type=anomaly only: detector signals the rule pages on (empty or omitted: every signal). An anomaly rule takes no comparator, threshold or window: it opens when the anomaly detector opens an anomaly in its scope (services, environments, anomaly_signals) at or above its severity, and resolves when that anomaly does."},"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"}},"anomalySignals":{"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"},"previewSkipped":{"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","anomaly"],"description":"What the rule measures. builder_query takes query_builder_draft, raw_query takes raw_query_sql, anomaly pages on the anomaly detector's incidents."},"anomaly_signals":{"type":"array","items":{"type":"string","enum":["error_rate","latency_p95","throughput","error_spike","log_volume"]},"description":"signal_type=anomaly only: detector signals the rule pages on (empty or omitted: every signal). An anomaly rule takes no comparator, threshold or window: it opens when the anomaly detector opens an anomaly in its scope (services, environments, anomaly_signals) at or above its severity, and resolves when that anomaly does. Replaces the current list; an empty list means every signal."},"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"}},"anomalySignals":{"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"}},"anomalySignals":{"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"},"previewSkipped":{"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}}, @@ -41,10 +41,17 @@ export const MCP_TOOL_DESCRIPTORS: ReadonlyArray = [ {"name":"create_alert_silence","title":"Create Alert Silence","description":"Hold back alert notifications for matching rules during a window (maintenance, a known outage). Incidents still open and resolve; only delivery is held. Matchers AND together, values inside one OR; pass at least one. services/environments must cover a rule's whole scope or the incident's group. Pass duration_minutes or ends_at (max 30 days).","inputSchema":{"type":"object","properties":{"reason":{"type":"string","description":"Why notifications are held back; shown on the silence and on held-back incidents"},"rule_ids":{"type":"array","items":{"type":"string"},"description":"Silence these alert rules (ids from list_alert_rules). Each is its UUID or its REST id (alrt_\u2026)"},"tags":{"type":"array","items":{"type":"string"},"description":"Silence rules carrying any of these tags"},"services":{"type":"array","items":{"type":"string"},"description":"Silence rules and incident groups covered by these services"},"environments":{"type":"array","items":{"type":"string"},"description":"Silence rules and incident groups covered by these environments"},"duration_minutes":{"type":"number","description":"How long the silence lasts from its start, in minutes"},"ends_at":{"type":"string","description":"When the silence ends, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601)"},"starts_at":{"type":"string","description":"When the silence starts (default: now), UTC (YYYY-MM-DD HH:mm:ss or ISO 8601)"}},"required":["reason"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"silence":{"type":"object","properties":{"id":{"type":"string"},"reason":{"type":"string"},"matchers":{"type":"object","properties":{"ruleIds":{"type":"array","items":{"type":"string"}},"tags":{"type":"array","items":{"type":"string"}},"services":{"type":"array","items":{"type":"string"}},"environments":{"type":"array","items":{"type":"string"}}},"additionalProperties":true},"status":{"type":"string"},"startsAt":{"type":"string"},"endsAt":{"type":"string"},"createdBy":{"type":"string"}},"required":["id","reason","matchers","status","startsAt","endsAt","createdBy"],"additionalProperties":true}},"required":["silence"],"additionalProperties":true},"annotations":{"title":"Create Alert Silence","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, {"name":"end_alert_silence","title":"End Alert Silence","description":"End an alert silence now so notifications resume; a scheduled one is cancelled. Ids from list_alert_silences.","inputSchema":{"type":"object","properties":{"silence_id":{"type":"string","description":"Silence to end: its UUID or its REST id (sil_\u2026)"}},"required":["silence_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"silence":{"type":"object","properties":{"id":{"type":"string"},"reason":{"type":"string"},"matchers":{"type":"object","properties":{"ruleIds":{"type":"array","items":{"type":"string"}},"tags":{"type":"array","items":{"type":"string"}},"services":{"type":"array","items":{"type":"string"}},"environments":{"type":"array","items":{"type":"string"}}},"additionalProperties":true},"status":{"type":"string"},"startsAt":{"type":"string"},"endsAt":{"type":"string"},"createdBy":{"type":"string"}},"required":["id","reason","matchers","status","startsAt","endsAt","createdBy"],"additionalProperties":true}},"required":["silence"],"additionalProperties":true},"annotations":{"title":"End Alert Silence","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"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, 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":"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"},"publicId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["kind","id","publicId","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"},"publicId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["kind","id","publicId","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, 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":{"kind":{"type":"string","enum":["destination","user","schedule"]},"id":{"type":"string"},"publicId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["kind","id","publicId","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":"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__/alert-silence-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts index 5b4cb121b6..b97a62a4a3 100644 --- a/apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts @@ -17,6 +17,7 @@ import { import { GetIncidentTimelineOutput, ListAlertSilencesOutput } from "@maple/domain/mcp-outputs" import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" +import { AlertIncidentPagesService } from "@maple/backend/services/alerts/AlertIncidentPagesService" import { CurrentMcpTenant } from "../../lib/query-warehouse" import { executeRegisteredMcpToolUnscoped } from "../registry" import type { McpToolResult } from "../types" @@ -106,6 +107,7 @@ const layer = (seen: Seen) => ) }, } as never), + Layer.mock(AlertIncidentPagesService)({ listPages: () => Effect.succeed([]) }), Layer.succeed(AlertReadModelsService, { listIncidents: () => Effect.succeed( 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 39e6c58ec0..3661e756c5 100644 --- a/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts @@ -35,6 +35,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 { 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" @@ -299,6 +300,39 @@ const layer = (seen: Seen) => }), ), } as never), + Layer.mock(AlertIncidentPagesService)({ + listPages: (_org, incidentIds) => + Effect.succeed( + Arr.flatMap(incidentIds, (incidentId) => + incidentId === INCIDENT_ID + ? Arr.map( + [ + ["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, errorMessage], index) => ({ + id: `page-${index}`, + incidentId, + createdAt: Date.parse(NOW), + page: { + userId: Schema.decodeSync(UserId)("user_ben"), + name: "Ben Brook", + channel, + status, + errorMessage, + }, + }), + ) + : [], + ), + ), + }), Layer.mock(EscalationChainsService)({ list: () => Effect.succeed( @@ -519,6 +553,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: 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/__tests__/escalation-chain-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/escalation-chain-tools.test.ts index de19414a12..e023c2cb8e 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( @@ -185,9 +220,7 @@ describe("escalation chain tools", () => { 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), - ) + expect(output.chains[0]?.steps[0]?.targets[0]?.publicId).toBe(encodePublicId("dest", PRIMARY)) for (const result of [listed, created]) { expect(text(result)).toContain(encodePublicId("esc", CHAIN_ID)) expect(text(result)).toContain(CHAIN_ID) @@ -204,6 +237,21 @@ describe("escalation chain tools", () => { 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) }, {}), + run("create_escalation_chain", { name: "Silent", steps: [{ wait_minutes: 5 }] }, {}), + run( + "create_escalation_chain", + { + name: "Wide", + steps: [ + { + destination_ids: Arr.makeBy(6, () => PRIMARY), + schedule_ids: Arr.makeBy(5, () => SCHEDULE), + wait_minutes: 5, + }, + ], + }, + {}, + ), ]) expect(Arr.map(failures, invalidParameter)).toEqual([ "repeat_count", @@ -212,12 +260,20 @@ describe("escalation chain tools", () => { "steps", "repeat_count", "name", + "steps", + "steps", ]) 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") + expect(messages[6]).toBe( + "steps[0]: needs at least one id in destination_ids, user_ids or schedule_ids", + ) + expect(messages[7]).toContain( + "steps[0]: destination_ids, user_ids and schedule_ids together: Expected", + ) }) it("create_escalation_chain decodes REST destination ids and JSON steps into the request", async () => { @@ -250,6 +306,44 @@ 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 (user_ana), whoever is on call on API primary (${encodePublicId("onc", SCHEDULE)}), 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 new file mode 100644 index 0000000000..7123b8cf69 --- /dev/null +++ b/apps/ai/src/mcp/tools/__tests__/oncall-schedule-tools.test.ts @@ -0,0 +1,440 @@ +/** + * 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, + OnCallScheduleInUseError, + OnCallScheduleNotFoundError, + EscalationChainId, + OnCallSchedulesListResponse, + OnCallShiftDocument, + 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 + /** An escalation chain still pages the schedule. */ + inUse?: 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] })) +} + +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 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 + 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: shift ?? null, + }), + ) + }, + shifts: (_org, _id, fromMs, toMs) => + 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() + 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 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(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 () => { + 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") + 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 8e352fea75..b67b4d025f 100644 --- a/apps/ai/src/mcp/tools/create-escalation-chain.ts +++ b/apps/ai/src/mcp/tools/create-escalation-chain.ts @@ -7,11 +7,11 @@ import { CurrentMcpTenant } from "../lib/query-warehouse" import { chainBlocks, createChainErrors, - destinationDirectory, invalidChainRequest, StepsInput, stepsParameterDescription, toEscalationChainRow, + targetDirectory, toStepsRequest, } from "../lib/escalation-chains" import * as P from "../lib/params" @@ -24,7 +24,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({ @@ -51,8 +51,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/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/get-incident-timeline.ts b/apps/ai/src/mcp/tools/get-incident-timeline.ts index 2d46ec6748..530ed9b3db 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, Option, Schema, Array as Arr, String as Str } from "effect" +import { DateTime, Effect, Option, Schema, Array as Arr, String as Str } from "effect" import { GetIncidentTimelineOutput } from "@maple/domain/mcp-outputs" import { CurrentMcpTenant } from "../lib/query-warehouse" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" +import { AlertIncidentPagesService } from "@maple/backend/services/alerts/AlertIncidentPagesService" import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" import type { AlertSilenceId, OrgId } from "@maple/domain/primitives" import { @@ -25,6 +26,38 @@ 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` + +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): string => + Arr.join( + Arr.map( + pages, + (page) => `${page.name ?? page.userId} by ${channelLabel(page.channel)}${pageStatus(page)}`, + ), + ", ", + ) + /** Reasons of the silences these incidents name; one that cannot be read is left out. */ const silenceReasons = Effect.fn("McpTool.getIncidentTimeline.silenceReasons")(function* ( orgId: OrgId, @@ -69,7 +102,7 @@ export function registerGetIncidentTimelineTool(server: McpToolRegistrar) { 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", @@ -113,38 +146,73 @@ 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 pagesService = yield* AlertIncidentPagesService + const pages = yield* pagesService + .listPages( + tenant.orgId, + Arr.map(incidents, (i) => i.id), + ) + .pipe( + Effect.mapError( + (error) => + new McpQueryError({ + message: error.message, + pipeName: "get_incident_timeline", + cause: error, + }), + ), + ) + const reasons = yield* silenceReasons( tenant.orgId, Arr.dedupe(Arr.flatMap(incidents, (i) => (i.silenceId === null ? [] : [i.silenceId]))), ) return { - incidents: Arr.map(incidents, (i) => ({ - id: i.id, - ruleId: i.ruleId, - ruleName: i.ruleName, - groupKey: i.groupKey, - ...(i.groupLabel === undefined ? undefined : { groupLabel: i.groupLabel }), - ...(i.labels === undefined ? undefined : { labels: i.labels }), - 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, - silenceId: i.silenceId, - silencedAt: i.silencedAt, - ...Option.match(Option.fromUndefinedOr(reasons.get(i.silenceId ?? "")), { - onNone: () => undefined, - onSome: (silenceReason) => ({ silenceReason }), - }), - 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, + ...(i.groupLabel === undefined ? undefined : { groupLabel: i.groupLabel }), + ...(i.labels === undefined ? undefined : { labels: i.labels }), + 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, + silenceId: i.silenceId, + silencedAt: i.silencedAt, + ...Option.match(Option.fromUndefinedOr(reasons.get(i.silenceId ?? "")), { + onNone: () => undefined, + onSome: (silenceReason) => ({ silenceReason }), + }), + 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, + ...(entry.page.errorMessage === null + ? undefined + : { errorMessage: entry.page.errorMessage }), + 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, @@ -188,6 +256,7 @@ export function registerGetIncidentTimelineTool(server: McpToolRegistrar) { ? undefined : `${formatTimestamp(inc.acknowledgedAt)} by ${inc.acknowledgedBy ?? "someone"}`, ], + ["Paged", inc.pages === undefined ? undefined : formatPages(inc.pages)], ["Notifications", silenceLine(inc)], ["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 50b49ad295..fac005172d 100644 --- a/apps/ai/src/mcp/tools/list-escalation-chains.ts +++ b/apps/ai/src/mcp/tools/list-escalation-chains.ts @@ -4,7 +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 { chainBlocks, destinationDirectory, toEscalationChainRow } from "../lib/escalation-chains" +import { chainBlocks, targetDirectory, toEscalationChainRow } from "../lib/escalation-chains" import { doc } from "../lib/tool-doc" export function registerListEscalationChainsTool(server: McpToolRegistrar) { @@ -24,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/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 3dd9005938..17b01f32df 100644 --- a/apps/ai/src/mcp/tools/mutating.ts +++ b/apps/ai/src/mcp/tools/mutating.ts @@ -24,6 +24,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 4d51b4304d..659e09906c 100644 --- a/apps/ai/src/mcp/tools/registry.ts +++ b/apps/ai/src/mcp/tools/registry.ts @@ -18,6 +18,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" @@ -446,6 +453,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 1df7c488e2..a7ec0fc8f0 100644 --- a/apps/ai/src/mcp/tools/runtime-requirements.ts +++ b/apps/ai/src/mcp/tools/runtime-requirements.ts @@ -3,8 +3,10 @@ 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" import type { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" import type { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService" import type { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService" @@ -50,8 +52,10 @@ export type McpToolRuntimeRequirements = | DependencyScanReader | AlertReadModelsService | AlertIncidentAckService + | AlertIncidentPagesService | AlertRulesService | EscalationChainsService + | OnCallSchedulesService | AlertSilencesService | DashboardPersistenceService | ErrorActorsService diff --git a/apps/ai/src/mcp/tools/update-escalation-chain.ts b/apps/ai/src/mcp/tools/update-escalation-chain.ts index a973fd9883..c4d828bfe8 100644 --- a/apps/ai/src/mcp/tools/update-escalation-chain.ts +++ b/apps/ai/src/mcp/tools/update-escalation-chain.ts @@ -6,11 +6,11 @@ import { EscalationChainsService } from "@maple/backend/services/alerts/Escalati import { CurrentMcpTenant } from "../lib/query-warehouse" import { chainBlocks, - destinationDirectory, invalidChainRequest, StepsInput, stepsParameterDescription, toEscalationChainRow, + targetDirectory, toStepsRequest, updateChainErrors, } from "../lib/escalation-chains" @@ -69,8 +69,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/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..cd91948f26 --- /dev/null +++ b/apps/ai/src/mcp/tools/who-is-on-call.ts @@ -0,0 +1,118 @@ +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", + "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) + // 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) + const atHandoff = + current.shift === null + ? null + : (yield* schedules.onCallAt(tenant.orgId, schedule.id, nextFrom).pipe(toError)).shift + const next = + 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, + "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 + ? output.onCall === null + ? "nobody within two weeks" + : "no handoff 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 d22f08e614..1d2b76232f 100644 --- a/apps/ai/test/mcp/unimplemented-tool-services.ts +++ b/apps/ai/test/mcp/unimplemented-tool-services.ts @@ -6,8 +6,10 @@ 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" import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" import { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService" import { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService" @@ -46,8 +48,10 @@ 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)({}), Layer.mock(AlertSilencesService)({}), Layer.mock(DashboardPersistenceService)({}), Layer.mock(ErrorActorsService)({}), 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 6fd917fdbf..fa73e651ae 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 @@ -187,6 +188,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)), AnomalyAlertSource.layer.pipe(Layer.provide(testDb.layer)), Layer.effect(SloAlertSource, SloAlertSource.make).pipe( diff --git a/apps/api/src/routes/v2/alert-incidents.http.ts b/apps/api/src/routes/v2/alert-incidents.http.ts index 97033234ec..4e4b17ed88 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" @@ -63,6 +67,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, }) @@ -70,6 +85,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 @@ -142,7 +158,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/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index 780a6497ab..2264b96b6d 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 { @@ -192,6 +193,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)), AnomalyAlertSource.layer.pipe(Layer.provide(testDb.layer)), Layer.effect(SloAlertSource, SloAlertSource.make).pipe( @@ -1568,6 +1575,331 @@ 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("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("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"]) + 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", + }), + // 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) + 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() + }) + 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"]) 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/oncall-schedules.http.ts b/apps/api/src/routes/v2/oncall-schedules.http.ts new file mode 100644 index 0000000000..25493813d1 --- /dev/null +++ b/apps/api/src/routes/v2/oncall-schedules.http.ts @@ -0,0 +1,279 @@ +import { HttpApiBuilder } from "effect/http-api" +import { + AlertValidationError, + 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, 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" + +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 }) + +/** + * 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 + + 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 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, + request, + ) + 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 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, + request, + ) + 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 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, + request, + ) + 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 4e4a60044b..c353b4f409 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -6,8 +6,10 @@ 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" import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" import { AnomalyDetectionService } from "@maple/backend/services/alerts/AnomalyDetectionService" import { ErrorsService } from "@maple/backend/services/errors/ErrorsService" @@ -49,6 +51,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 { HttpV2AlertSilencesLive } from "./alert-silences.http" import { HttpV2AlertRulesLive } from "./alert-rules.http" import { HttpV2ApiKeysLive } from "./api-keys.http" @@ -188,10 +191,34 @@ 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 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(AlertIncidentAckService.layer, OrgMembersServiceStubLayer)), + Layer.provide( + Layer.mergeAll( + Layer.effect(AlertIncidentAckService, AlertIncidentAckService.make), + Layer.effect(AlertIncidentPagesService, AlertIncidentPagesService.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; the empty member directory accepts every participant. + HttpV2OnCallSchedulesLive.pipe( + Layer.provide( + Layer.effect(OnCallSchedulesService, OnCallSchedulesService.make).pipe( + Layer.provide(OrgMembersServiceStubLayer), + ), + ), ), // Real service: it needs only the Database every harness already provides. HttpV2EscalationChainsLive.pipe(Layer.provide(EscalationChainsService.layer)), diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index 92cd4fce7f..b328a2f179 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 { HttpV2AlertSilencesLive } from "@/routes/v2/alert-silences.http" import { HttpV2AlertRulesLive } from "@/routes/v2/alert-rules.http" import { HttpV2AnomaliesLive } from "@/routes/v2/anomalies.http" @@ -186,6 +187,7 @@ const ApiV2Routes = HttpApiBuilder.layer(MapleApiV2).pipe( HttpV2AlertDestinationsLive, HttpV2AlertIncidentsLive, HttpV2EscalationChainsLive, + HttpV2OnCallSchedulesLive, HttpV2AlertSilencesLive, HttpV2IngestKeysLive, HttpV2SourcesLive, diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index 901e8de3a0..21b6cee2cb 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -10,8 +10,10 @@ 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" import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" import { AnomalyDetectionService } from "@maple/backend/services/alerts/AnomalyDetectionService" @@ -181,10 +183,12 @@ export const HttpServicesLive = Layer.mergeAll( AlertDestinationsService.layer, AlertReadModelsService.layer, AlertIncidentAckService.layer, + AlertIncidentPagesService.layer, AlertRulesService.layer, AlertSilencesService.layer, AlertsService.layer, EscalationChainsService.layer, + OnCallSchedulesService.layer, AnomalyDetectionService.layer, AiTriageService.layer, InvestigationService.layer, diff --git a/apps/chat-bot/src/relay/settle.test.ts b/apps/chat-bot/src/relay/settle.test.ts index d695e943de..f2a5882ab2 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 e96ce7980e..934fff28ba 100644 --- a/apps/chat-bot/src/relay/turn.test.ts +++ b/apps/chat-bot/src/relay/turn.test.ts @@ -144,6 +144,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/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index bde9636011..fb8d62ffbe 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -14150,6 +14150,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": "Maintenance windows for alerting: quiet a deploy or a planned migration without disabling rules. Rules keep evaluating and incidents still open and resolve; only notifications are held back.", "name": "Alert Silences" diff --git a/apps/landing/src/content/docs/reference/mcp.md b/apps/landing/src/content/docs/reference/mcp.md index b5e7877368..52280e9adb 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 | | `list_alert_silences` | read | Active and scheduled alert silences, or ended ones, with their matchers and window | | `create_alert_silence` | write | Hold back notifications for matching rules for a duration or until a time | | `end_alert_silence` | write | End a silence now so notifications resume; cancels a scheduled one | 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 65bae76ba6..1ad5cddd0a 100644 --- a/apps/web/src/page/investigations/conversation/kit/tool-metadata.ts +++ b/apps/web/src/page/investigations/conversation/kit/tool-metadata.ts @@ -71,6 +71,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 @@ -147,6 +154,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/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..811d210b33 --- /dev/null +++ b/packages/alerting-core/src/oncall.test.ts @@ -0,0 +1,424 @@ +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 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)) + +// 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("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({ + 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") + // 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", () => { + 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: DAN_OVERRIDE, + userId: "dan", + startsAt: at("2026-03-04T10:00:00Z"), + endsAt: at("2026-03-04T14:00:00Z"), + createdAt: 1, + } + const eve = { + id: EVE_OVERRIDE, + 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: 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([ + { 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.effective, (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"])) + // Thousands of full renders: seconds of CPU, more under a loaded machine. + }, 30_000) +}) + +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: 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 new file mode 100644 index 0000000000..e716fc3003 --- /dev/null +++ b/packages/alerting-core/src/oncall.ts @@ -0,0 +1,385 @@ +import { Array as Arr, DateTime, Option, Order, String as Str } from "effect" +import { ONCALL_LOOKAROUND_MS } from "@maple/domain/http" +import type { + OnCallLayer, + OnCallOverride, + OnCallRestriction, + OnCallRotation, + OnCallScheduleConfig, + OnCallWeekday, +} from "@maple/domain/http" +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 +// 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 { ONCALL_LOOKAROUND_MS } + +export type OnCallSource = + | { readonly kind: "override"; readonly overrideId: OnCallOverrideId } + | { 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, 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 +} + +interface Interval { + readonly startMs: number + 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 + +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) => { + 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 + 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, + full: Option.getOrElse(clip(window, shift.full.startMs, shift.full.endMs), () => 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 + +// 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)) + +/** + * 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 { 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, () => []), effective: [] } + 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) + 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)) + } + + // 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 && samePiece(last, winner)) { + acc[acc.length - 1] = { ...last, endMs: segment.endMs } + } else { + acc.push({ ...winner, ...segment }) + } + return acc + }, + }), + ) + return { layers, effective } +} + +/** + * 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 => { + 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, + }), + ), + ), + ) +} diff --git a/packages/backend/src/services/alerts/AlertIncidentAck.chat.test.ts b/packages/backend/src/services/alerts/AlertIncidentAck.chat.test.ts index 0d22a88a74..4a2e96807b 100644 --- a/packages/backend/src/services/alerts/AlertIncidentAck.chat.test.ts +++ b/packages/backend/src/services/alerts/AlertIncidentAck.chat.test.ts @@ -28,9 +28,10 @@ const createdDbs: TestDb[] = [] afterEach(() => cleanupTestDbs(createdDbs)) const makeLayer = (testDb: TestDb) => - Layer.mergeAll(AlertIncidentAckService.layer, AlertChatCardService.layer).pipe( - Layer.provide(testDb.layer), - ) + Layer.mergeAll( + Layer.effect(AlertIncidentAckService, AlertIncidentAckService.make), + AlertChatCardService.layer, + ).pipe(Layer.provide(testDb.layer)) const destination = (id: string, name: string, type: string, enabled: boolean) => `('${id}', '${ORG}', '${name}', '${type}', ${enabled}, '{}', '', '', '', now(), now(), 'u', 'u')` diff --git a/packages/backend/src/services/alerts/AlertIncidentAckService.test.ts b/packages/backend/src/services/alerts/AlertIncidentAckService.test.ts index 72a81e5c21..ea299e2368 100644 --- a/packages/backend/src/services/alerts/AlertIncidentAckService.test.ts +++ b/packages/backend/src/services/alerts/AlertIncidentAckService.test.ts @@ -22,7 +22,8 @@ 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)) const seed = (testDb: TestDb) => Effect.promise(() => 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..1caf288a56 --- /dev/null +++ b/packages/backend/src/services/alerts/AlertIncidentPagesService.ts @@ -0,0 +1,123 @@ +/** + * 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)]) + // 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), + ), + ) + 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(Arr.reverse(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/AlertMemberNotifier.ts b/packages/backend/src/services/alerts/AlertMemberNotifier.ts new file mode 100644 index 0000000000..86bda87621 --- /dev/null +++ b/packages/backend/src/services/alerts/AlertMemberNotifier.ts @@ -0,0 +1,530 @@ +/** + * 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, + DEFAULT_ALERT_DELIVERY_RETRY_POLICY, +} from "@maple/alerting-core" +import { + AlertComparator, + AlertEventType, + AlertPersistenceError, + AlertSeverity, + AlertSignalType, + ChatConnectorId, + type AlertDeliveryFailure, + type ChatWorkspaceId, + type OrgId, +} from "@maple/domain/http" +import * as PG from "@maple-dev/effect-orm/postgres" +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" +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 = 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", + 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", + 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({ + 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({ + 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) +const decodeConnectorId = Schema.decodeUnknownOption(ChatConnectorId) + +/** 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)), + ) + + /** + * Takes the row from the attempt count it was read at and counts this attempt, so a + * crash after the claim still spends one. A row out of attempts is failed instead. + */ + const claim = (row: AlertMemberNotificationRow, nowMs: number) => + dbExecute((db) => + db.run( + PG.update(AlertMemberNotifications) + .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 + ? ("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 = ( + 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), + ]), + ), + ) + }) + + /** Read after the claim, so an ack or resolve that landed during the drain still wins. */ + const incidentOf = (row: AlertMemberNotificationRow) => + 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, + 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(FailureMessages.directoryNotConfigured, false) + : failure(FailureMessages.directoryUnavailable, true) + } + const member = Arr.findFirst( + members.value, + (candidate) => candidate.userId === row.userId, + ) + if (Option.isNone(member)) { + return failure(FailureMessages.noEmail, false) + } + // A deployment that cannot send email never will: fail now instead of retrying. + if (!email.isConfigured) return failure(FailureMessages.emailNotConfigured, 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.orElseSucceed(() => failure(FailureMessages.emailFailed, true)), + ) + }).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, + ) => + 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, + members: (orgId: OrgId) => Exit.Exit, { readonly _tag: string }>, + ) { + yield* Effect.annotateCurrentSpan({ + orgId: 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 + 1, + }) + const annotateStatus = (status: string) => + 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 ( + Option.isNone(incident) || + incident.value.status !== "open" || + incident.value.acknowledgedAt !== null + ) { + const acknowledged = Option.isSome(incident) && incident.value.status === "open" + // A page with earlier attempts was on its way, so it stopped rather than never went. + const tried = row.attemptNumber > 0 + yield* settle(row, claimedAt, { + status: "skipped", + 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", + }) + 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(FailureMessages.unreadable, 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(attemptNumber, attempt.retryable) + const jitter = yield* Random.nextIntBetween(0, 1_000) + yield* settle( + row, + claimedAt, + retry + ? { + status: "queued", + scheduledAt: attemptedAt + alertDeliveryRetryDelayMs(attemptNumber, jitter), + attemptedAt, + errorMessage: attempt.message, + } + : { status: "failed", attemptedAt, errorMessage: attempt.message }, + ) + yield* annotateStatus(retry ? "retrying" : "failed") + // Fixed messages only: never the address or what the platform said about it. + yield* Effect.logWarning("Escalation page to a member failed").pipe( + Effect.annotateLogs({ + notificationId: row.id, + incidentId: row.incidentId, + channel: row.channel, + attemptNumber, + reason: attempt.message, + willRetry: retry, + }), + ) + return "failed" 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), + ), + ) + 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, + ), + ), + Effect.repeat({ while: (more) => more }), + ) + const result = yield* Ref.get(totals) + yield* Effect.annotateCurrentSpan({ + "maple.escalation.member_notifications_processed": result.processedCount, + "maple.escalation.member_notifications_failed": result.failureCount, + }) + return result + }).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 a8a9203736..773eb8bea8 100644 --- a/packages/backend/src/services/alerts/AlertsService.delivery.test.ts +++ b/packages/backend/src/services/alerts/AlertsService.delivery.test.ts @@ -471,6 +471,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 79438f24b0..50ac156653 100644 --- a/packages/backend/src/services/alerts/AlertsService.escalation.test.ts +++ b/packages/backend/src/services/alerts/AlertsService.escalation.test.ts @@ -31,8 +31,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 decodeIso = Schema.decodeUnknownSync(IsoDateTimeString) @@ -70,7 +72,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..7cd23cefbf --- /dev/null +++ b/packages/backend/src/services/alerts/AlertsService.oncall-paging.test.ts @@ -0,0 +1,970 @@ +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, 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 { AlertIncidentPagesService } from "./AlertIncidentPagesService" +import { ChatAlertPoster } from "./ChatAlertPoster" +import { EscalationChainsService } from "./EscalationChainsService" +import { OnCallSchedulesService } from "./OnCallSchedulesService" +import { memberNotificationDedupeKey } from "./alert-escalation-runs" +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 + /** 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") + +/** 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) }, + directMessages: true, + requiredConfig: [], + transport: Effect.succeed({ + post: (target, blocks) => + 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 }) + 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, + }), +] + +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, + 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 directory = options.directory ?? [...MEMBERS] + const timeoutMs = options.deliveryTimeoutMs + const members = Layer.succeed(OrgMembersService, stubOrgMembersService(directory)) + return Layer.mergeAll( + makeLayer( + testDb, + makeWarehouseStub(state), + { + fetch: okFetch, + ...(timeoutMs === undefined ? undefined : { deliveryTimeoutMs: () => timeoutMs }), + }, + options.email ?? stubEmailService(emails), + ChatAlertPoster.layer.pipe( + Layer.provide(Layer.succeed(ChatConnectorRegistry, [fakeSlack(chat)])), + ), + directory, + ), + 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(AlertIncidentPagesService, AlertIncidentPagesService.make).pipe( + Layer.provide(testDb.layer), + Layer.provide(members), + ), + ) +} + +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* () { + 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], + ) + }) + +/** 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 + /** Who step 2 pages instead of the schedule. */ + readonly secondStep?: EscalationStep["targets"] + } = {}, +) => + 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: options.secondStep ?? [{ 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), []) + // 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. + 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 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(pages, (entry) => [entry.page.name, entry.page.channel, entry.page.status]), + [ + ["Ben Brook", "email", "sent"], + ["Ben Brook", "slack", "sent"], + ], + ) + 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))) + }) + }) + + 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', 0, + 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.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", + ) + }).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]], + ) + // 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))) + }) + }) + + 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) } + 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, row.error_message]), + [["queued", 1, "The chat platform could not be reached"]], + ) + 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))) + }) + }) + + 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) } + 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", () => { + 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 1c27abd2ca..5fc2922b2a 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -150,6 +150,9 @@ import { destinationsUpTo, escalationChainFor, ruleAtSeverity } from "./alert-th import { applyEscalationPlan, enabledDestinationIds, + loadStepRecipients, + stepDestinationIds, + withoutDepartedMembers, type EscalationWorkRow, alignDueEscalationStep, endEscalationRunsAbove, @@ -165,6 +168,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" @@ -210,6 +215,7 @@ import { mapSignalUnit, resolveSignalDisplay } from "./alert-signal-display" import { StoredDeliveryPayloadSchema, type StoredDeliveryPayload } from "./delivery/stored-payload" import { incidentAcknowledgedBy } from "./delivery/transports/chat" import { summarizeCause } from "@maple/backend/platform/describe-cause" +import { AlertMemberNotifier } from "./AlertMemberNotifier" const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)) @@ -642,6 +648,15 @@ 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), @@ -6011,6 +6026,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, + ), + }), + ) + /** * The silence covering a run's incident now, if any; a due step under one is deferred. A rule * whose stored config no longer decodes cannot be matched, and its chain keeps paging. @@ -6076,16 +6112,55 @@ 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 resolved = + plan.kind === "send" + ? 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( + 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({ @@ -6097,6 +6172,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..575c07710a 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 @@ -36,8 +36,8 @@ import type { 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 (a connector that reports a rate limit with a 400). + case "rate_limited": + return new AlertDeliveryError(fields) case undefined: return error.status === undefined ? new AlertDeliveryError(fields) @@ -67,8 +70,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 +134,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 +322,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.test.ts b/packages/backend/src/services/alerts/OnCallSchedulesService.test.ts new file mode 100644 index 0000000000..ddc6a71eda --- /dev/null +++ b/packages/backend/src/services/alerts/OnCallSchedulesService.test.ts @@ -0,0 +1,357 @@ +import { afterEach, assert, describe, it } from "@effect/vitest" +import { + AlertMemberDirectoryNotConfiguredError, + AlertValidationError, + OnCallOverrideCreateRequest, + OnCallScheduleCreateRequest, + OnCallScheduleUpdateRequest, + OrgId, + RoleName, + UserId, +} from "@maple/domain/http" +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" +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)) + +/** 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( + 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))) + }) + + 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 new file mode 100644 index 0000000000..cb7b4c01fe --- /dev/null +++ b/packages/backend/src/services/alerts/OnCallSchedulesService.ts @@ -0,0 +1,684 @@ +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, + OnCallScheduleInUseError, + OnCallScheduleNotFoundError, + OnCallSchedulesListResponse, + OnCallShiftDocument, + OnCallShiftsResponse, + type AlertMemberDirectoryNotConfiguredError, + type AlertMemberDirectoryUnavailableError, + type EscalationChainId, + 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 { + EscalationChains, + 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 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 => ({ + 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, + chainIds: ReadonlyArray, +) => { + 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) }), + 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; unchecked on a deployment with no directory. */ + const requireMembers = (orgId: OrgId, userIds: ReadonlyArray) => + orgMembers.resolveMembers(orgId, userIds).pipe( + 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) => + 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, nearby, active, chains] = 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), + // 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) => + toDocument( + row, + Arr.filter(overrides, (o) => o.scheduleId === row.id), + nowMs, + chains.get(row.id) ?? [], + ), + ), + }) + }) + + 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), + chainIdsBySchedule(db, orgId), + ]).pipe( + Effect.map(([row, overrides, chains]) => + toDocument(row, overrides, nowMs, chains.get(row.id) ?? []), + ), + ) + } + + 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* () { + // 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(($) => [ + $.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(() => [])) + // 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 { + 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/alerts/alert-escalation-runs.ts b/packages/backend/src/services/alerts/alert-escalation-runs.ts index fc0c0f9e89..a174623d24 100644 --- a/packages/backend/src/services/alerts/alert-escalation-runs.ts +++ b/packages/backend/src/services/alerts/alert-escalation-runs.ts @@ -6,7 +6,9 @@ */ import { ESCALATION_SILENCE_RECHECK_MS, + ONCALL_LOOKAROUND_MS, makeAlertDeliveryKey, + onCallAt, planEscalationAdvance, type EscalationAdvancePlan, } from "@maple/alerting-core" @@ -15,20 +17,27 @@ import type { AlertIncidentId, AlertSeverity, EscalationChainId, + EscalationStep, EscalationSteps, + OnCallScheduleId, OrgId, } from "@maple/domain/http" -import type { AlertEscalationRunId } from "@maple/domain/primitives" +import type { AlertEscalationRunId, ChatConnectorId, ChatIdentityId, 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 @@ -271,6 +280,163 @@ 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 to a linked chat account. */ +export type MemberRecipient = + | { readonly userId: UserId; readonly channel: "email" } + | { + readonly userId: UserId + readonly channel: ChatConnectorId + /** The `chat_identities` row; the workspace is chosen at delivery. */ + readonly identityId: ChatIdentityId + 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 + /** 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, on a + * connector in `directMessageConnectors` with a workspace linked in the org. + */ +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] : [], + ) + 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 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( + { 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 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] : [], + ) + 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("id", "userId", "connector", "externalUserId", "createdAt") + .where(($) => [$.orgId.eq(orgId), $.userId.in_(...userIds)]) + .orderBy(["createdAt", "asc"], ["id", "asc"]), + ), + db.run( + PG.from(ChatWorkspaces) + .select("connector") + .where(($) => [$.orgId.eq(orgId)]), + ), + ]) + 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, + identityId: identity.id, + externalUserId: identity.externalUserId, + }, + ] + : [], + ) + const recipients = Arr.flatMap(userIds, (userId): ReadonlyArray => [ + { userId, channel: "email" }, + ...Arr.filter(chatRecipients, (recipient) => recipient.userId === userId), + ]) + 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> @@ -295,6 +461,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 } @@ -360,16 +528,11 @@ export const applyEscalationPlan = (db: MapleDb, input: ApplyEscalationPlanInput passCount: row.repeatCount + 1, }, } - const stepDestinationIds = Arr.dedupe( - Arr.filter( - Arr.map(plan.step.targets, (target) => target.destinationId), - (id) => input.enabled.has(id), - ), - ) + const enabledIds = Arr.filter(stepDestinationIds(plan.step), (id) => input.enabled.has(id)) // 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 + enabledIds.length === 0 ? [] : yield* db.run( PG.from(AlertDeliveryEvents) @@ -378,7 +541,7 @@ export const applyEscalationPlan = (db: MapleDb, input: ApplyEscalationPlanInput $.orgId.eq(row.orgId), $.incidentId.eq(row.incidentId), $.deliveryKey.in_( - ...Arr.map(stepDestinationIds, (destinationId) => + ...Arr.map(enabledIds, (destinationId) => makeAlertDeliveryKey( row.incidentId, destinationId, @@ -390,12 +553,13 @@ export const applyEscalationPlan = (db: MapleDb, input: ApplyEscalationPlanInput ]), ) const destinationIds = Arr.filter( - stepDestinationIds, + enabledIds, (id) => !Arr.some(renotified, (delivery) => delivery.destinationId === id), ) - if (stepDestinationIds.length === 0) { + const recipients = input.recipients ?? [] + if (enabledIds.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, @@ -434,6 +598,41 @@ 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", + // Attempts started: each claim counts one before it sends. + attemptNumber: 0, + scheduledAt: nowMs, + claimedAt: null, + claimExpiresAt: null, + payloadJson: { ...payload, recipient }, + dedupeKey: memberNotificationDedupeKey( + row.id, + plan.passIndex + 1, + plan.stepIndex + 1, + recipient, + ), + 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 2846be5c94..530d3fca80 100644 --- a/packages/backend/src/services/alerts/alerts-service-test-support.ts +++ b/packages/backend/src/services/alerts/alerts-service-test-support.ts @@ -48,6 +48,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" @@ -253,6 +254,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, pushes, ...runtimeOverrides } = overrides const noPushes = { sent: 0, failed: 0, unregistered: 0, skipped: 0 } @@ -300,7 +302,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)), ) @@ -326,7 +328,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(mobilePushLive), @@ -343,8 +350,11 @@ export const makeLayer = ( Layer.provide(alertDestinationsLive), Layer.provide(alertReadModelsLive), Layer.provide(alertRulesLive), - Layer.provide(UptimeAlertSource.layer.pipe(Layer.provide(databaseLive))), - Layer.provide(AnomalyAlertSource.layer.pipe(Layer.provide(databaseLive))), + Layer.provide( + Layer.mergeAll(UptimeAlertSource.layer, AnomalyAlertSource.layer).pipe( + Layer.provide(databaseLive), + ), + ), Layer.provide( Layer.effect(SloAlertSource, SloAlertSource.make).pipe( Layer.provide(Layer.mergeAll(databaseLive, warehouseLive)), 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 487356c2af..c2c52f54ec 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", () => { @@ -234,6 +232,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/alerts/delivery/transports/chat.ts b/packages/backend/src/services/alerts/delivery/transports/chat.ts index 789b0b42dd..e8a015185b 100644 --- a/packages/backend/src/services/alerts/delivery/transports/chat.ts +++ b/packages/backend/src/services/alerts/delivery/transports/chat.ts @@ -10,8 +10,8 @@ import { alertAccentColor, truncate, } from "../../alert-formatting" -import type { TemplateRenderContext } from "../../alert-formatting" -import type { EffectTransport, ResolvedTitleBody, SecretConfigOf } from "../Transport" +import type { DispatchContext } from "../context" +import type { EffectTransport, RenderInput, SecretConfigOf } from "../Transport" type Config = SecretConfigOf<"chat"> @@ -21,6 +21,15 @@ 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 incidentAcknowledgedBy?: string | null + } + 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. @@ -30,12 +39,6 @@ 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 interface ChatAlertCardInput { - readonly context: TemplateRenderContext & { readonly incidentAcknowledgedBy?: string | null } - readonly templated: ResolvedTitleBody | null - readonly linkUrl: string -} - /** The name an open, acknowledged incident shows on its alert cards; null otherwise. */ export const incidentAcknowledgedBy = ( incident: { 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/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/backend/src/services/org/OrganizationService.ts b/packages/backend/src/services/org/OrganizationService.ts index 352cc3e727..59395555d1 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, @@ -41,6 +42,8 @@ import { ApiKeys, CancellationReviews, EscalationChains, + OnCallOverrides, + OnCallSchedules, ChatIdentities, ChatWorkspaces, CliDeviceAuthorizations, @@ -147,8 +150,12 @@ 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. + OnCallOverrides, + OnCallSchedules, AlertRuleStates, AlertRules, AlertSilences, diff --git a/packages/chat-platform/src/connectors/discord/outbound.test.ts b/packages/chat-platform/src/connectors/discord/outbound.test.ts index 7f30e09f43..7f510223b7 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,66 @@ 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("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", + "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 a4bd645816..64d8cb9168 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) + ? "rate_limited" + : 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. * @@ -243,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 @@ -289,11 +317,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 }) @@ -354,6 +395,7 @@ 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..886e2c80c9 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( @@ -235,6 +236,7 @@ const jsonRequest = (url: string, payload: SlackApiRequest) => 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: [], @@ -492,6 +494,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 22b0c75574..a334be9048 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..840f3cb3a3 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 } /** @@ -255,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}. * @@ -279,15 +292,17 @@ 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), /** * 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()), }, ) {} diff --git a/packages/db/drizzle/20261106232100_oncall_schedules/migration.sql b/packages/db/drizzle/20261106232100_oncall_schedules/migration.sql new file mode 100644 index 0000000000..ffcfbf31b9 --- /dev/null +++ b/packages/db/drizzle/20261106232100_oncall_schedules/migration.sql @@ -0,0 +1,70 @@ +-- 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 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 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 +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 "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; +--> 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/20261106232100_oncall_schedules/snapshot.json b/packages/db/drizzle/20261106232100_oncall_schedules/snapshot.json new file mode 100644 index 0000000000..190a40f4de --- /dev/null +++ b/packages/db/drizzle/20261106232100_oncall_schedules/snapshot.json @@ -0,0 +1,20182 @@ +{ + "version": "1", + "dialect": "postgres", + "id": "87e233c29a897b724eeeeafd7ede56fbf29ae28de0088ebb34a9471478ea98a6", + "prevIds": [ + "4361f2a1117955ec64a027e89bc55571a9faf6aa3baee809ed87ae9d71361f98" + ], + "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": "alert_silences", + "primaryKey": { + "name": "alert_silences_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "anomaly_detector_settings", + "primaryKey": { + "name": "anomaly_detector_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "anomaly_detector_states", + "primaryKey": { + "name": "anomaly_detector_states_org_id_detector_key_pk", + "columns": [ + "org_id", + "detector_key" + ] + } + }, + { + "kind": "table", + "name": "anomaly_incidents", + "primaryKey": { + "name": "anomaly_incidents_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "api_keys", + "primaryKey": { + "name": "api_keys_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "aws_connections", + "primaryKey": { + "name": "aws_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cancellation_reviews", + "primaryKey": { + "name": "cancellation_reviews_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "chat_identities", + "primaryKey": { + "name": "chat_identities_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "chat_workspaces", + "primaryKey": { + "name": "chat_workspaces_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cli_device_authorizations", + "primaryKey": { + "name": "cli_device_authorizations_pkey", + "columns": [ + "device_code_hash" + ] + } + }, + { + "kind": "table", + "name": "cloudflare_analytics_state", + "primaryKey": { + "name": "cloudflare_analytics_state_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cloudflare_hyperdrive_configs", + "primaryKey": { + "name": "cloudflare_hyperdrive_configs_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cloudflare_logpush_connectors", + "primaryKey": { + "name": "cloudflare_logpush_connectors_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cloudflare_zone_settings", + "primaryKey": { + "name": "cloudflare_zone_settings_pkey", + "columns": [ + "org_id", + "account_id" + ] + } + }, + { + "kind": "table", + "name": "dashboard_shares", + "primaryKey": { + "name": "dashboard_shares_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + { + "kind": "table", + "name": "dashboard_versions", + "primaryKey": { + "name": "dashboard_versions_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + { + "kind": "table", + "name": "dashboards", + "primaryKey": { + "name": "dashboards_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + { + "kind": "table", + "name": "dependency_findings", + "primaryKey": { + "name": "dependency_findings_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "dependency_scan_matches", + "primaryKey": { + "name": "dependency_scan_matches_pkey", + "columns": [ + "scan_id", + "lockfile_path", + "ecosystem", + "package_name", + "version", + "advisory_id" + ] + } + }, + { + "kind": "table", + "name": "dependency_scans", + "primaryKey": { + "name": "dependency_scans_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "device_credentials", + "primaryKey": { + "name": "device_credentials_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "device_proof_replays", + "primaryKey": { + "name": "device_proof_replays_pkey", + "columns": [ + "key" + ] + } + }, + { + "kind": "table", + "name": "digest_subscriptions", + "primaryKey": { + "name": "digest_subscriptions_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_fingerprint_candidates", + "primaryKey": { + "name": "error_fingerprint_candidates_org_id_fingerprint_hash_pk", + "columns": [ + "org_id", + "fingerprint_hash" + ] + } + }, + { + "kind": "table", + "name": "error_incidents", + "primaryKey": { + "name": "error_incidents_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_events", + "primaryKey": { + "name": "error_issue_events_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_fingerprints", + "primaryKey": { + "name": "error_issue_fingerprints_org_id_fingerprint_hash_pk", + "columns": [ + "org_id", + "fingerprint_hash" + ] + } + }, + { + "kind": "table", + "name": "error_issue_pull_requests", + "primaryKey": { + "name": "error_issue_pull_requests_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_states", + "primaryKey": { + "name": "error_issue_states_org_id_issue_id_pk", + "columns": [ + "org_id", + "issue_id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_verifications", + "primaryKey": { + "name": "error_issue_verifications_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issues", + "primaryKey": { + "name": "error_issues_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_notification_deliveries", + "primaryKey": { + "name": "error_notification_deliveries_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_notification_policies", + "primaryKey": { + "name": "error_notification_policies_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "error_tick_states", + "primaryKey": { + "name": "error_tick_states_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "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": "silence_id", + "position": 18, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "payload_json", + "position": 19, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "created_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "updated_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "enabled", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "config_json", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "secret_ciphertext", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "secret_iv", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "secret_tag", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "last_tested_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "last_test_error", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "consecutive_failures", + "position": 11, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "last_failure_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "disabled_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "disabled_reason", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "created_by", + "position": 17, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "updated_by", + "position": 18, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_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": "anomaly_incident_id", + "position": 29, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "group_label", + "position": 30, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "labels_json", + "position": 31, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "silence_id", + "position": 32, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "silenced_at", + "position": 33, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "created_at", + "position": 34, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "updated_at", + "position": 35, + "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": "anomaly_signals_json", + "position": 10, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "tags_json", + "position": 11, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "signal_type", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "comparator", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "threshold", + "position": 14, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "threshold_upper", + "position": 15, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "thresholds_json", + "position": 16, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "window_minutes", + "position": 17, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "evaluation_interval_minutes", + "position": 18, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "slowed_interval_minutes", + "position": 19, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "slowed_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "minimum_sample_count", + "position": 21, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "consecutive_breaches_required", + "position": 22, + "type": "integer", + "notNull": true, + "default": "2", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "breaches_out_of", + "position": 23, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "consecutive_healthy_required", + "position": 24, + "type": "integer", + "notNull": true, + "default": "2", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "renotify_interval_minutes", + "position": 25, + "type": "integer", + "notNull": true, + "default": "30", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "apdex_threshold_ms", + "position": 26, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "query_builder_draft_json", + "position": 27, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "raw_query_sql", + "position": 28, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "group_by", + "position": 29, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "destination_ids_json", + "position": 30, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "query_spec_json", + "position": 31, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "reducer", + "position": 32, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "sample_count_strategy", + "position": 33, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "no_data_behavior", + "position": 34, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "last_scheduled_at", + "position": 35, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "created_at", + "position": 36, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "updated_at", + "position": 37, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "created_by", + "position": 38, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "updated_by", + "position": 39, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "reason", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "matchers_json", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "starts_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "ends_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "updated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "created_by", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "updated_by", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "sensitivity", + "position": 2, + "type": "text", + "notNull": true, + "default": "'normal'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "muted_signals_json", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "last_tick_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "updated_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "updated_by", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "detector_key", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "signal_type", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "service_name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "deployment_env", + "position": 4, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "fingerprint_hash", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "consecutive_breaches", + "position": 6, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "consecutive_healthy", + "position": 7, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_status", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_value", + "position": 9, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "baseline_median", + "position": 10, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_sample_count", + "position": 11, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_evaluated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "open_incident_id", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_resolved_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_incident_id", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "detector_key", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "signal_type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "service_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "deployment_env", + "position": 5, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "fingerprint_hash", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "error_issue_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "status", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "severity", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "opened_value", + "position": 10, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "baseline_median", + "position": 11, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "baseline_sigma", + "position": 12, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "threshold_value", + "position": 13, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_observed_value", + "position": 14, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_sample_count", + "position": 15, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "first_triggered_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_triggered_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "resolved_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "resolve_reason", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "triage_status", + "position": 20, + "type": "text", + "notNull": true, + "default": "'none'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "dedupe_key", + "position": 21, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "fingerprints_json", + "position": 22, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "reopen_count", + "position": 23, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_reopened_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "created_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "updated_at", + "position": 26, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "description", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "key_hash", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "key_prefix", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "revoked", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "revoked_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "last_used_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "expires_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "metadata_json", + "position": 10, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "scopes", + "position": 11, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "kind", + "position": 12, + "type": "text", + "notNull": true, + "default": "'standard'", + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "created_by", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "created_by_email", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "account_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "modules", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "granted_modules", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "template_version", + "position": 6, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "verified_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "last_checked_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "last_check_error", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "check_lease_until", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "metric_stream", + "position": 11, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "metric_stream_changed_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_secret_ciphertext", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_secret_iv", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_secret_tag", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_secret_hash", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_last_received_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_last_error", + "position": 18, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "created_by", + "position": 19, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "created_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "updated_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "plan_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "subscription_started_at", + "position": 3, + "type": "bigint", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "canceled_at", + "position": 4, + "type": "bigint", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "snapshot_json", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "rule_reason", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "model_read", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "posted_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "connector", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "external_user_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "user_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "display_name", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "connector", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "external_workspace_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "settings", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "credentials_ciphertext", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "credentials_iv", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "credentials_tag", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "device_code_hash", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "user_code_hash", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "device_name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_org_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_user_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_roles", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_user_email", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "api_key_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "token_ciphertext", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "token_iv", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "token_tag", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "denied_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "expires_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "account_id", + "position": 2, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "dataset", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "zone_id", + "position": 4, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "zone_name", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "enabled", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "watermark_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "backfill_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "settings_json", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "settings_fetched_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "quantiles_available", + "position": 11, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "discovered_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "live_scripts_json", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "grant_call_cap", + "position": 14, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "last_success_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "last_error", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "last_error_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "lease_until", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "created_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "updated_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "account_id", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "config_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_host", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_port", + "position": 6, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_scheme", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_database", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_user", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "deleted_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "created_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "updated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "zone_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "service_name", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "dataset", + "position": 6, + "type": "text", + "notNull": true, + "default": "'http_requests'", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_ciphertext", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_iv", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_tag", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_hash", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "enabled", + "position": 11, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "last_received_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "last_error", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_rotated_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "created_by", + "position": 17, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "updated_by", + "position": 18, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_zone_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_zone_settings", + "name": "account_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_zone_settings", + "name": "watch_new_zones", + "position": 2, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_zone_settings", + "name": "zone_overrides", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": "'{}'", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_zone_settings", + "name": "created_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_zone_settings", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_zone_settings", + "name": "updated_by", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "dashboard_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "widget_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "mode", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_hash", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_ciphertext", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_iv", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_tag", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_suffix", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "created_by", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "updated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "updated_by", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "revoked_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "dashboard_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "version_number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "snapshot_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "change_kind", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "change_summary", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "source_version_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "created_by", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "payload_json", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "created_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "created_by", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "updated_by", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "version", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "lockfile_path", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "ecosystem", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "package_name", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "advisory_id", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "aliases", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "severity", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "summary", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "versions", + "position": 10, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "fixed_in", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "relation", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "status", + "position": 13, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "first_seen_scan_id", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "last_seen_scan_id", + "position": 15, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "first_seen_sha", + "position": 16, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "last_seen_sha", + "position": 17, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "fixed_scan_id", + "position": 18, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "fixed_sha", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "status_reason", + "position": 20, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "status_changed_by", + "position": 21, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "status_changed_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "created_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "updated_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "scan_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "lockfile_path", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "ecosystem", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "package_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "version", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "relation", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "advisory_id", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "aliases", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "severity", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "summary", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "fixed_in", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "commit_sha", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "ref", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "trigger", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "default_branch_head", + "position": 6, + "type": "boolean", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "status", + "position": 7, + "type": "text", + "notNull": true, + "default": "'running'", + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "lockfiles", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "package_count", + "position": 9, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "vulnerable_count", + "position": 10, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "error", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "started_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "finished_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "user_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "installation_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "public_jwk", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "jkt", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "roles", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "scopes", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "attested", + "position": 8, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "attestation", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "last_used_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "expires_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "hard_expires_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "revoked_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_proof_replays", + "name": "key", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_proof_replays", + "name": "expires_at", + "position": 1, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "user_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "email", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "enabled", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "opted_out_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "day_of_week", + "position": 6, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "timezone", + "position": 7, + "type": "text", + "notNull": true, + "default": "'UTC'", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "send_hour", + "position": 8, + "type": "integer", + "notNull": true, + "default": "9", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "namespaces_json", + "position": 9, + "type": "text", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "environments_json", + "position": 10, + "type": "text", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "last_sent_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "last_attempted_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_enabled", + "position": 13, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_opted_out_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_last_sent_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_last_attempted_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "created_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "updated_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "fingerprint_hash", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "service_name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "exception_type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "exception_message", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "error_label", + "position": 5, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "top_frame", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "service_versions_json", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "occurrence_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "first_seen_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "last_seen_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "status", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "reason", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "first_triggered_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "last_triggered_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "resolved_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "occurrence_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "actor_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "type", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "from_state", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "to_state", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "payload_json", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": "'{}'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_fingerprints", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_fingerprints", + "name": "fingerprint_hash", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_fingerprints", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_fingerprints", + "name": "source", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_fingerprints", + "name": "origin_issue_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_fingerprints", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "provider", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "external_repo_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "repo_full_name", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "number", + "position": 6, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "url", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "title", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "author_login", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "state", + "position": 10, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "merged_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "merge_commit_sha", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "link_source", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "linked_by_actor_id", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "issue_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "last_observed_occurrence_at", + "position": 2, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "last_evaluated_at", + "position": 3, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "open_incident_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "pull_request_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "status", + "position": 4, + "type": "text", + "notNull": true, + "default": "'waiting'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "merged_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "verify_after", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "baseline_versions_json", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "baseline_occurrence_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "baseline_rate_per_hour", + "position": 9, + "type": "double precision", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "investigation_id", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "verdict", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "verdict_note", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "post_merge_occurrence_count", + "position": 13, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "attempt", + "position": 14, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "kind", + "position": 2, + "type": "text", + "notNull": true, + "default": "'error'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "source_ref_json", + "position": 3, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "fingerprint_hash", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "fingerprint_version", + "position": 5, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "service_name", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "exception_type", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "exception_message", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "error_label", + "position": 9, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "top_frame", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "workflow_state", + "position": 11, + "type": "text", + "notNull": true, + "default": "'triage'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "priority", + "position": 12, + "type": "integer", + "notNull": true, + "default": "3", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "severity", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "severity_source", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "assigned_actor_id", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "lease_holder_actor_id", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "lease_expires_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "claimed_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "notes", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "first_seen_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "last_seen_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "occurrence_count", + "position": 22, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "resolved_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "resolved_by_actor_id", + "position": 24, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "last_resolved_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "last_regressed_at", + "position": 26, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "regression_count", + "position": 27, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "seen_versions_json", + "position": 28, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "resolved_versions_json", + "position": 29, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "snooze_until", + "position": 30, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "archived_at", + "position": 31, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "merged_into_issue_id", + "position": 32, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "created_at", + "position": 33, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "updated_at", + "position": 34, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "destination_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "delivery_key", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "payload_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "status", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "attempt_count", + "position": 6, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "scheduled_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "claimed_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "claim_expires_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "claimed_by", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "attempted_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "error_message", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "updated_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "destination_ids_json", + "position": 2, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_first_seen", + "position": 3, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_regression", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_resolve", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_transition_in_review", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_transition_done", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_claim", + "position": 8, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "min_occurrence_count", + "position": 9, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "severity", + "position": 10, + "type": "text", + "notNull": true, + "default": "'warning'", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "updated_by", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "processed_through", + "position": 1, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "bootstrap_completed", + "position": 2, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "claim_token", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "claim_expires_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "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": "claimed_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "dedupe_key", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "error", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "processed_at", + "position": 16, + "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": "alert_silences", + "name": "alert_silences_org_ends_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"ends_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_detector_states", + "name": "anomaly_detector_states_evaluated_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"last_evaluated_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_detector_states", + "name": "anomaly_detector_states_open_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"open_incident_id\"" + ], + "where": "\"open_incident_id\" is not null" + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_open_detector_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"detector_key\"" + ], + "where": "\"status\" = 'open'" + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_detector_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"detector_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"error_issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_status_triggered_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"", + "\"last_triggered_at\"", + "\"id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_triggered_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"last_triggered_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "api_keys", + "name": "api_keys_key_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"key_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "api_keys", + "name": "api_keys_org_id_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "aws_connections", + "name": "aws_connections_org_account_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"account_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "aws_connections", + "name": "aws_connections_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "aws_connections", + "name": "aws_connections_stream_secret_hash_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"stream_secret_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cancellation_reviews", + "name": "cancellation_reviews_subscription_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"plan_id\"", + "\"subscription_started_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_identities", + "name": "chat_identities_org_connector_external_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"connector\"", + "\"external_user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_identities", + "name": "chat_identities_org_connector_user_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"connector\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_identities", + "name": "chat_identities_org_user_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_workspaces", + "name": "chat_workspaces_connector_external_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"connector\"", + "\"external_workspace_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_workspaces", + "name": "chat_workspaces_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cli_device_authorizations", + "name": "cli_device_authorizations_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cli_device_authorizations", + "name": "cli_device_authorizations_user_code_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"user_code_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_analytics_state", + "name": "cf_analytics_state_org_account_dataset_zone_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"account_id\"", + "\"dataset\"", + "\"zone_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_analytics_state", + "name": "cf_analytics_state_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_hyperdrive_configs", + "name": "cloudflare_hyperdrive_configs_org_config_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"config_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_hyperdrive_configs", + "name": "cloudflare_hyperdrive_configs_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_secret_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"secret_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_id_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_live_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"", + "coalesce(widget_id, '')" + ], + "where": "revoked_at is null" + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_org_dashboard_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_token_hash_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"token_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_versions", + "name": "dashboard_versions_org_dashboard_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"", + "\"version_number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_versions", + "name": "dashboard_versions_org_dashboard_version_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"", + "\"version_number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboards", + "name": "dashboards_org_name_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboards", + "name": "dashboards_org_updated_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"updated_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dependency_findings", + "name": "dependency_findings_key_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"lockfile_path\"", + "\"ecosystem\"", + "\"package_name\"", + "\"advisory_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dependency_findings", + "name": "dependency_findings_org_status_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dependency_findings", + "name": "dependency_findings_org_updated_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"updated_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dependency_scan_matches", + "name": "dependency_scan_matches_org_scan_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"scan_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dependency_scans", + "name": "dependency_scans_org_started_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"started_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dependency_scans", + "name": "dependency_scans_repo_commit_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"commit_sha\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dependency_scans", + "name": "dependency_scans_repo_started_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"started_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "device_credentials", + "name": "device_credentials_jkt_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"jkt\"" + ], + "where": null + }, + { + "kind": "index", + "table": "device_credentials", + "name": "device_credentials_live_installation_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"installation_id\"" + ], + "where": "\"revoked_at\" IS NULL" + }, + { + "kind": "index", + "table": "device_credentials", + "name": "device_credentials_user_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "device_proof_replays", + "name": "device_proof_replays_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "digest_subscriptions", + "name": "digest_subscriptions_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "digest_subscriptions", + "name": "digest_subscriptions_org_user_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_fingerprint_candidates", + "name": "error_fingerprint_candidates_last_seen_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"last_seen_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_incidents", + "name": "error_incidents_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_incidents", + "name": "error_incidents_org_status_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"", + "\"last_triggered_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_events", + "name": "error_issue_events_actor_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"actor_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_events", + "name": "error_issue_events_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_events", + "name": "error_issue_events_type_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"type\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_fingerprints", + "name": "error_issue_fingerprints_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_pull_requests", + "name": "error_issue_pull_requests_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_pull_requests", + "name": "error_issue_pull_requests_issue_pr_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"", + "\"provider\"", + "\"repo_full_name\"", + "\"number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_pull_requests", + "name": "error_issue_pull_requests_repo_number_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"provider\"", + "\"repo_full_name\"", + "\"number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_verifications", + "name": "error_issue_verifications_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"verify_after\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_verifications", + "name": "error_issue_verifications_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_verifications", + "name": "error_issue_verifications_open_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": "\"status\" in ('waiting', 'running')" + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_lease_expiry_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"lease_expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_archived_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"archived_at\"" + ], + "where": "\"archived_at\" is not null" + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_assignee_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"assigned_actor_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_fp_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"fingerprint_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_fp_version_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"fingerprint_version\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_live_seen_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"last_seen_at\"", + "\"id\"" + ], + "where": "\"archived_at\" is null" + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_merged_into_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"merged_into_issue_id\"" + ], + "where": "\"merged_into_issue_id\" is not null" + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_severity_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"severity\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_workflow_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"workflow_state\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_notification_deliveries", + "name": "error_notification_deliveries_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"scheduled_at\"", + "\"claim_expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_notification_deliveries", + "name": "error_notification_deliveries_key_destination_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"delivery_key\"", + "\"destination_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_notification_deliveries", + "name": "error_notification_deliveries_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_tick_states", + "name": "error_tick_states_claim_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"claim_expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "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..26656830f8 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,53 @@ 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), + /** 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" }), + 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}`, 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" }), + 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 24a45760e2..fc354972ca 100644 --- a/packages/db/src/tables/index.ts +++ b/packages/db/src/tables/index.ts @@ -42,7 +42,8 @@ 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" import { GcpResources } from "./gcp-resources" @@ -129,6 +130,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" @@ -206,6 +208,9 @@ export const allTables: ReadonlyArray> = [ IssueEscalations, EscalationChains, AlertEscalationRuns, + AlertMemberNotifications, + 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/audit-actions.ts b/packages/domain/src/audit-actions.ts index cef705b045..3e674c26fd 100644 --- a/packages/domain/src/audit-actions.ts +++ b/packages/domain/src/audit-actions.ts @@ -72,6 +72,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, @@ -194,6 +202,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", alert_silence: "Alert silence", anomaly_incident: "Anomaly", anomaly_settings: "Anomaly detection", @@ -249,6 +259,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", "alert_silence.created": "Silenced alerts", "alert_silence.updated": "Updated alert silence", "alert_silence.ended": "Ended alert silence", diff --git a/packages/domain/src/generated/anticipated-error-identifiers.ts b/packages/domain/src/generated/anticipated-error-identifiers.ts index a8668df1e5..7f7622e2bb 100644 --- a/packages/domain/src/generated/anticipated-error-identifiers.ts +++ b/packages/domain/src/generated/anticipated-error-identifiers.ts @@ -88,6 +88,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/alerts.ts b/packages/domain/src/http/alerts.ts index e5afd6182f..5e4a9d6ea8 100644 --- a/packages/domain/src/http/alerts.ts +++ b/packages/domain/src/http/alerts.ts @@ -163,12 +163,14 @@ export type AlertIncidentAckSource = Schema.Schema.Type(" silencedAt: Schema.NullOr(IsoDateTimeString), }) {} +/** 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", )({ @@ -1103,6 +1131,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/index.ts b/packages/domain/src/http/index.ts index 74e05644ce..01dfeacd2a 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 "./alert-silences" export * from "./agent-feedback" export * from "./mcp-connections" diff --git a/packages/domain/src/http/oncall-schedules.ts b/packages/domain/src/http/oncall-schedules.ts new file mode 100644 index 0000000000..c7f6385691 --- /dev/null +++ b/packages/domain/src/http/oncall-schedules.ts @@ -0,0 +1,258 @@ +import { DateTime, Option, Schema } from "effect" +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 +/** 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( + 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 + +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: OnCallLayers, +}) +export type OnCallScheduleConfig = Schema.Schema.Type + +/** Beats every layer; among overlapping overrides the most recently created wins. */ +export const OnCallOverride = Schema.Struct({ + id: OnCallOverrideId, + 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 + +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/domain/src/http/v2/alert-incidents.ts b/packages/domain/src/http/v2/alert-incidents.ts index 8b2ea2b686..731b25416e 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, @@ -212,7 +213,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({ @@ -221,6 +222,26 @@ 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; on a `queued` page, why the last attempt failed before the retry.", + }), + }).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/api.ts b/packages/domain/src/http/v2/api.ts index 9dafc8fba0..83009efa3a 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 { V2AlertSilencesApiGroup } from "./alert-silences" import { V2AlertRulesApiGroup } from "./alert-rules" import { V2ApiKeysApiGroup } from "./api-keys" @@ -108,6 +109,7 @@ export class MapleApiV2 extends HttpApi.make("MapleApiV2") .add(V2AlertDestinationsApiGroup) .add(V2AlertIncidentsApiGroup) .add(V2EscalationChainsApiGroup) + .add(V2OnCallSchedulesApiGroup) .add(V2AlertSilencesApiGroup) .add(V2IngestKeysApiGroup) .add(V2SourcesApiGroup) diff --git a/packages/domain/src/http/v2/escalation-chains.ts b/packages/domain/src/http/v2/escalation-chains.ts index 52c8a4a3db..de98031b74 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 @@ -86,7 +117,7 @@ const escalationChainExample = { wait_minutes: 10, }, { - targets: [{ type: "destination", destination_id: "dest_cTp8s4DaBKsNmcma6yJGSn" }], + targets: [{ type: "schedule", schedule_id: "onc_cTp8s4DaBKsNmcma6yJGSn" }], wait_minutes: 15, }, ], @@ -133,7 +164,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, @@ -182,13 +214,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( @@ -223,13 +257,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.", }), ), ) @@ -238,7 +272,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/index.ts b/packages/domain/src/http/v2/index.ts index 4622141382..e69251129a 100644 --- a/packages/domain/src/http/v2/index.ts +++ b/packages/domain/src/http/v2/index.ts @@ -15,6 +15,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..fc183e4031 --- /dev/null +++ b/packages/domain/src/http/v2/oncall-schedules.ts @@ -0,0 +1,683 @@ +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 + +// 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, { 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"] }), + 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, { 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: OnCallInstant.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, { 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}.` }) + +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: OnCallInstant, + ends_at: OnCallInstant.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 8aa41551c2..9542a96ffb 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", @@ -208,6 +215,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/alerts/silences/{id}", "PATCH /v2/anomalies/settings", @@ -231,6 +239,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 ac8e0d9fa9..d6c0453da7 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", alertSilence: "sil", actor: "actor", auditLogEntry: "alog", 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/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({ diff --git a/packages/domain/src/http/v2/v2-contract.test.ts b/packages/domain/src/http/v2/v2-contract.test.ts index 8f55995da0..6016956bca 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, @@ -445,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", @@ -457,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)], @@ -465,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() @@ -475,6 +488,87 @@ 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", () => { + 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", () => { diff --git a/packages/domain/src/mcp-outputs/alerts.ts b/packages/domain/src/mcp-outputs/alerts.ts index 548c5b8c41..bf8afc37d8 100644 --- a/packages/domain/src/mcp-outputs/alerts.ts +++ b/packages/domain/src/mcp-outputs/alerts.ts @@ -335,6 +335,21 @@ export const IncidentTimelineRow = Schema.Struct({ silenceReason: Schema.optionalKey(Schema.String), 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, + /** Why a page failed or was skipped, or why a queued one is being retried. */ + errorMessage: Schema.optionalKey(Schema.String), + at: Schema.String, + }), + ), + ), }) export const AcknowledgeAlertIncidentOutput = Schema.Struct({ diff --git a/packages/domain/src/mcp-outputs/catalog.ts b/packages/domain/src/mcp-outputs/catalog.ts index b2f1bb2e2c..ae552458e4 100644 --- a/packages/domain/src/mcp-outputs/catalog.ts +++ b/packages/domain/src/mcp-outputs/catalog.ts @@ -53,6 +53,14 @@ import { EscalationChainMutationOutput, ListEscalationChainsOutput, } from "./escalation-chains" +import { + DeleteOnCallOverrideOutput, + DeleteOnCallScheduleOutput, + ListOnCallSchedulesOutput, + OnCallOverrideMutationOutput, + OnCallScheduleMutationOutput, + WhoIsOnCallOutput, +} from "./oncall-schedules" import { AddDashboardWidgetOutput, CreateDashboardOutput, @@ -148,6 +156,8 @@ export const McpToolOutputs = { compare_periods: ComparePeriodsOutput, create_alert_rule: CreateAlertRuleOutput, create_escalation_chain: EscalationChainMutationOutput, + create_oncall_override: OnCallOverrideMutationOutput, + create_oncall_schedule: OnCallScheduleMutationOutput, create_alert_silence: AlertSilenceMutationOutput, create_ingest_rule: IngestRuleMutationOutput, create_scrape_target: ScrapeTargetMutationOutput, @@ -160,6 +170,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, @@ -201,6 +213,7 @@ export const McpToolOutputs = { list_alert_incidents: ListAlertIncidentsOutput, list_alert_rules: ListAlertRulesOutput, list_escalation_chains: ListEscalationChainsOutput, + list_oncall_schedules: ListOnCallSchedulesOutput, list_alert_silences: ListAlertSilencesOutput, list_dashboards: ListDashboardsOutput, list_error_incidents: ListErrorIncidentsOutput, @@ -259,6 +272,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, @@ -267,6 +281,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/escalation-chains.ts b/packages/domain/src/mcp-outputs/escalation-chains.ts index a8ac177161..06f332cbdb 100644 --- a/packages/domain/src/mcp-outputs/escalation-chains.ts +++ b/packages/domain/src/mcp-outputs/escalation-chains.ts @@ -4,11 +4,14 @@ import { Schema } from "effect" 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. */ + /** A destination, a member paged directly, or whoever is on call on a schedule. */ + kind: Schema.Literals(["destination", "user", "schedule"]), + id: Schema.String, + /** The target's id in the REST API: `dest_…`, `onc_…`, or the user id as-is. */ + publicId: 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, }) diff --git a/packages/domain/src/mcp-outputs/index.ts b/packages/domain/src/mcp-outputs/index.ts index 40cfae87f1..4a5c3494b1 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 "./alert-silences" export * from "./ingest-rules" export * from "./dashboards" 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, +}) diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index f28843252f..029162c4fa 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -205,6 +205,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 diff --git a/packages/web-data/src/alerts/incident-timeline.test.ts b/packages/web-data/src/alerts/incident-timeline.test.ts index 57fb256b6f..999f2425f1 100644 --- a/packages/web-data/src/alerts/incident-timeline.test.ts +++ b/packages/web-data/src/alerts/incident-timeline.test.ts @@ -224,6 +224,86 @@ 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("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 2c0316d60e..7ee960512a 100644 --- a/packages/web-data/src/alerts/incident-timeline.ts +++ b/packages/web-data/src/alerts/incident-timeline.ts @@ -93,6 +93,25 @@ function activityEntry(event: V2AlertIncidentEvent): IncidentTimelineEntry { title: "Acknowledgement cleared", detail: "The incident escalated, so it notified again", } + 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` + // 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 } + 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 } + } } }