diff --git a/apps/studio/lib/ai/tools/incident-tools.test.ts b/apps/studio/lib/ai/tools/incident-tools.test.ts index f7b73a8e77f..c2e0ccee842 100644 --- a/apps/studio/lib/ai/tools/incident-tools.test.ts +++ b/apps/studio/lib/ai/tools/incident-tools.test.ts @@ -244,4 +244,220 @@ describe('ai/tools/incident-tools', () => { }) }) }) + + describe('with useStatusPageWidget enabled', () => { + beforeEach(async () => { + const common = await import('common') + vi.spyOn(common, 'IS_PLATFORM', 'get').mockReturnValue(true) + }) + + const baseIncident = { + id: 'incident-1', + name: 'Elevated error rates', + url: 'https://status.supabase.com/incidents/incident-1', + last_update_at: '2026-01-01T00:00:00Z', + last_update_message: 'We are investigating.', + affected_components: [], + status: 'investigating', + current_worst_impact: 'partial_outage', + } + + const baseMaintenance = { + id: 'maintenance-1', + name: 'Scheduled database maintenance', + url: 'https://status.supabase.com/maintenances/maintenance-1', + last_update_at: '2026-01-01T00:00:00Z', + last_update_message: null, + affected_components: [], + status: 'maintenance_in_progress', + started_at: '2026-01-01T00:00:00Z', + } + + function statusPagePayload(overrides: Record = {}) { + return { + page_title: 'Supabase', + page_url: 'https://status.supabase.com', + ongoing_incidents: [], + in_progress_maintenances: [], + scheduled_maintenances: [], + ...overrides, + } + } + + it('fetches from the status page endpoint', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => statusPagePayload(), + }) + + const tools = getIncidentTools({ + baseUrl: 'https://example.com/dashboard', + useStatusPageWidget: true, + }) + if (!tools.get_active_incidents.execute) throw new Error('execute is undefined') + await tools.get_active_incidents.execute({}, executeOptions) + + expect(mockFetch).toHaveBeenCalledWith('https://example.com/dashboard/api/status-page', { + signal: expect.any(AbortSignal), + }) + }) + + it('drops incidents and maintenances where visible is false', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => + statusPagePayload({ + ongoing_incidents: [ + { ...baseIncident, visible: false, show_banner: false }, + { ...baseIncident, id: 'incident-2', visible: true, show_banner: true }, + ], + in_progress_maintenances: [{ ...baseMaintenance, visible: false, show_banner: false }], + }), + }) + + const tools = getIncidentTools({ + baseUrl: 'https://supabase.com/dashboard', + useStatusPageWidget: true, + }) + const result = await (tools.get_active_incidents.execute as any)({}, executeOptions) + + expect((result as any).incidents).toHaveLength(1) + expect((result as any).incidents[0]).toMatchObject({ + kind: 'incident', + name: 'Elevated error rates', + }) + }) + + it('includes items with show_banner set to false', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => + statusPagePayload({ + ongoing_incidents: [{ ...baseIncident, visible: true, show_banner: false }], + }), + }) + + const tools = getIncidentTools({ + baseUrl: 'https://supabase.com/dashboard', + useStatusPageWidget: true, + }) + const result = await (tools.get_active_incidents.execute as any)({}, executeOptions) + + expect((result as any).incidents).toHaveLength(1) + }) + + it('combines ongoing incidents and in-progress maintenances, excluding scheduled maintenances', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => + statusPagePayload({ + ongoing_incidents: [{ ...baseIncident, visible: true, show_banner: true }], + in_progress_maintenances: [{ ...baseMaintenance, visible: true, show_banner: true }], + scheduled_maintenances: [ + { + id: 'scheduled-1', + name: 'Upcoming maintenance', + url: 'https://status.supabase.com/maintenances/scheduled-1', + last_update_at: '2026-01-01T00:00:00Z', + last_update_message: null, + affected_components: [], + status: 'maintenance_scheduled', + starts_at: '2026-02-01T00:00:00Z', + ends_at: '2026-02-01T01:00:00Z', + visible: true, + show_banner: true, + banner_lead_days: 1, + }, + ], + }), + }) + + const tools = getIncidentTools({ + baseUrl: 'https://supabase.com/dashboard', + useStatusPageWidget: true, + }) + const result = await (tools.get_active_incidents.execute as any)({}, executeOptions) + + expect((result as any).incidents).toHaveLength(2) + expect((result as any).incidents.map((incident: any) => incident.kind).sort()).toEqual([ + 'incident', + 'maintenance', + ]) + expect((result as any).message).toContain('status.supabase.com') + }) + + it('describes maintenance-only results as maintenance, not as an incident', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => + statusPagePayload({ + in_progress_maintenances: [{ ...baseMaintenance, visible: true, show_banner: true }], + }), + }) + + const tools = getIncidentTools({ + baseUrl: 'https://supabase.com/dashboard', + useStatusPageWidget: true, + }) + const result = await (tools.get_active_incidents.execute as any)({}, executeOptions) + + expect((result as any).incidents).toHaveLength(1) + expect((result as any).message).toContain('1 in-progress maintenance') + expect((result as any).message).not.toContain('active incident') + }) + + it('returns the no-active-incidents message when nothing is visible', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => statusPagePayload(), + }) + + const tools = getIncidentTools({ + baseUrl: 'https://supabase.com/dashboard', + useStatusPageWidget: true, + }) + const result = await (tools.get_active_incidents.execute as any)({}, executeOptions) + + expect(result).toEqual({ + incidents: [], + message: expect.stringContaining('No active incidents'), + }) + }) + + it('returns an error when the response fails to parse', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ this_is: 'not a valid status page response' }), + }) + + const tools = getIncidentTools({ + baseUrl: 'https://supabase.com/dashboard', + useStatusPageWidget: true, + }) + const result = await (tools.get_active_incidents.execute as any)({}, executeOptions) + + expect(result).toEqual({ + incidents: [], + error: 'Unable to check incident status at this time.', + }) + }) + + it('returns an error on a non-ok response', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + }) + + const tools = getIncidentTools({ + baseUrl: 'https://supabase.com/dashboard', + useStatusPageWidget: true, + }) + const result = await (tools.get_active_incidents.execute as any)({}, executeOptions) + + expect(result).toEqual({ + incidents: [], + error: 'Unable to check incident status at this time.', + }) + }) + }) }) diff --git a/apps/studio/lib/ai/tools/incident-tools.ts b/apps/studio/lib/ai/tools/incident-tools.ts index dd4277f54e3..ffb8e0e0acb 100644 --- a/apps/studio/lib/ai/tools/incident-tools.ts +++ b/apps/studio/lib/ai/tools/incident-tools.ts @@ -3,14 +3,64 @@ import { IS_PLATFORM } from 'common' import { z } from 'zod' import type { IncidentInfo } from '@/lib/api/incident-status' +import { + StatusPageResponseSchema, + type AffectedComponent, +} from '@/lib/status-page/status-page.schema' + +const INCIDENT_STATUS_ERROR = { + incidents: [], + error: 'Unable to check incident status at this time.', +} +const NO_ACTIVE_INCIDENTS_MESSAGE = 'No active incidents.' +const DEFAULT_STATUS_PAGE_URL = 'https://status.supabase.com' + +function timeoutSignal(abortSignal?: AbortSignal) { + return abortSignal + ? AbortSignal.any([abortSignal, AbortSignal.timeout(5_000)]) + : AbortSignal.timeout(5_000) +} + +function formatAffectedComponents(affectedComponents: Array): Array { + return affectedComponents.map((component) => + component.group_name ? `${component.group_name} / ${component.name}` : component.name + ) +} + +function buildActiveIncidentsMessage( + incidentCount: number, + maintenanceCount: number, + statusPageUrl: string +) { + const parts: Array = [] + if (incidentCount > 0) { + parts.push(`${incidentCount} active incident${incidentCount === 1 ? '' : 's'}`) + } + if (maintenanceCount > 0) { + parts.push(`${maintenanceCount} in-progress maintenance${maintenanceCount === 1 ? '' : 's'}`) + } + + const totalCount = incidentCount + maintenanceCount + + return `There ${totalCount === 1 ? 'is' : 'are'} ${parts.join(' and ')} on Supabase infrastructure. If the user's issue appears related, inform them about the ongoing issue(s) and direct them to ${statusPageUrl} for real-time updates.` +} /** * Creates incident-related tools for the AI assistant. * * @param baseUrl - The base URL for API requests (e.g., https://supabase.com/dashboard) * This should be the public URL to leverage CDN caching. + * @param useStatusPageWidget - When true, reads active incidents from the incident.io + * Widget API-backed status page endpoint instead of the + * legacy incident-status endpoint. */ -export const getIncidentTools = ({ baseUrl }: { baseUrl: string }) => ({ +export const getIncidentTools = ({ + baseUrl, + useStatusPageWidget, +}: { + baseUrl: string + useStatusPageWidget?: boolean +}) => ({ get_active_incidents: tool({ description: 'Check for active incidents. Use this tool when the user reports issues with any Supabase service, including the database, authentication, realtime, storage, and functions. Possible problems include, but are not limited to, connection issues, timeouts, service unavailability, authentication failures, or unexpected errors.', @@ -23,16 +73,84 @@ export const getIncidentTools = ({ baseUrl }: { baseUrl: string }) => ({ } } + if (useStatusPageWidget) { + try { + const response = await fetch(`${baseUrl}/api/status-page`, { + signal: timeoutSignal(abortSignal), + }) + + if (!response.ok) { + console.warn('Failed to fetch status page:', response.status) + return INCIDENT_STATUS_ERROR + } + + const json = await response.json() + const parseResult = StatusPageResponseSchema.safeParse(json) + + if (!parseResult.success) { + console.warn('Failed to parse status page response:', parseResult.error) + return INCIDENT_STATUS_ERROR + } + + const { data } = parseResult + + const incidents = data.ongoing_incidents + .filter((incident) => incident.visible) + .map((incident) => ({ + kind: 'incident' as const, + name: incident.name, + status: incident.status, + impact: incident.current_worst_impact, + affected_components: formatAffectedComponents(incident.affected_components), + last_update_message: incident.last_update_message ?? null, + url: incident.url, + })) + + const maintenances = data.in_progress_maintenances + .filter((maintenance) => maintenance.visible) + .map((maintenance) => ({ + kind: 'maintenance' as const, + name: maintenance.name, + status: maintenance.status, + affected_components: formatAffectedComponents(maintenance.affected_components), + last_update_message: maintenance.last_update_message ?? null, + url: maintenance.url, + started_at: maintenance.started_at, + })) + + const combined = [...incidents, ...maintenances] + + if (combined.length === 0) { + return { + incidents: [], + message: NO_ACTIVE_INCIDENTS_MESSAGE, + } + } + + const statusPageUrl = data.page_url || DEFAULT_STATUS_PAGE_URL + + return { + incidents: combined, + message: buildActiveIncidentsMessage( + incidents.length, + maintenances.length, + statusPageUrl + ), + } + } catch (error) { + console.warn('Failed to fetch status page:', error) + return INCIDENT_STATUS_ERROR + } + } + try { const response = await fetch(`${baseUrl}/api/incident-status`, { - signal: abortSignal - ? AbortSignal.any([abortSignal, AbortSignal.timeout(5_000)]) - : AbortSignal.timeout(5_000), + signal: timeoutSignal(abortSignal), }) if (!response.ok) { console.warn('Failed to fetch incident status:', response.status) - return { incidents: [], error: 'Unable to check incident status at this time.' } + return INCIDENT_STATUS_ERROR } const incidents: IncidentInfo[] = await response.json() @@ -40,8 +158,7 @@ export const getIncidentTools = ({ baseUrl }: { baseUrl: string }) => ({ if (incidents.length === 0) { return { incidents: [], - message: - 'No active incidents. The issue the user is experiencing is likely not related to a Supabase infrastructure problem.', + message: NO_ACTIVE_INCIDENTS_MESSAGE, } } @@ -54,11 +171,11 @@ export const getIncidentTools = ({ baseUrl }: { baseUrl: string }) => ({ return { incidents: incidentSummaries, - message: `There ${incidents.length === 1 ? 'is' : 'are'} ${incidents.length} active incident${incidents.length === 1 ? '' : 's'} on Supabase infrastructure. If the user's issue appears related, inform them about the ongoing incident(s) and direct them to https://status.supabase.com for real-time updates.`, + message: buildActiveIncidentsMessage(incidents.length, 0, DEFAULT_STATUS_PAGE_URL), } } catch (error) { console.warn('Failed to fetch incident status:', error) - return { incidents: [], error: 'Unable to check incident status at this time.' } + return INCIDENT_STATUS_ERROR } }, }), diff --git a/apps/studio/lib/ai/tools/index.ts b/apps/studio/lib/ai/tools/index.ts index 30fad97232f..78d359707e3 100644 --- a/apps/studio/lib/ai/tools/index.ts +++ b/apps/studio/lib/ai/tools/index.ts @@ -21,6 +21,7 @@ export const getTools = async ({ baseUrl, supportMode, isExplorerEnabled, + useStatusPageWidget, signal, }: { projectRef: string @@ -34,6 +35,7 @@ export const getTools = async ({ // assistant must not advertise list_notebooks/get_notebook until the caller confirms the // flag is on for this user. isExplorerEnabled?: boolean + useStatusPageWidget?: boolean // Required: tools fetched from the remote MCP server hold an HTTP connection // that is closed when this signal aborts (i.e. when the request ends). signal: AbortSignal @@ -86,7 +88,7 @@ export const getTools = async ({ aiOptInLevel, }) : {}), - ...(baseUrl ? getIncidentTools({ baseUrl }) : {}), + ...(baseUrl ? getIncidentTools({ baseUrl, useStatusPageWidget }) : {}), } } diff --git a/apps/studio/lib/server/configcat.ts b/apps/studio/lib/server/configcat.ts index 9de056afd42..ecbab5f54d3 100644 --- a/apps/studio/lib/server/configcat.ts +++ b/apps/studio/lib/server/configcat.ts @@ -1,4 +1,5 @@ import { getClient, PollingMode, User } from '@configcat/sdk/node' +import { IS_PLATFORM } from 'common' let serverClient: ReturnType @@ -71,3 +72,10 @@ export async function getServerFlags( return client.getAllValuesAsync(buildUser(userEmail, customAttributes)) } + +export async function isServerFlagEnabled(name: string): Promise { + if (!IS_PLATFORM) return false + + const flags = await getServerFlags() + return flags.some((flag) => flag.settingKey === name && flag.settingValue === true) +} diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts index 46a8f795406..f0e4fe3998e 100644 --- a/apps/studio/pages/api/ai/sql/generate-v4.ts +++ b/apps/studio/pages/api/ai/sql/generate-v4.ts @@ -31,7 +31,7 @@ import { encodeNotebookToolError } from '@/lib/ai/tools/notebook-tools' import { apiWrapper } from '@/lib/api/apiWrapper' import { executeQuery } from '@/lib/api/self-hosted/query' import { getURL } from '@/lib/helpers' -import { trustedUserEmail } from '@/lib/server/configcat' +import { isServerFlagEnabled, trustedUserEmail } from '@/lib/server/configcat' export const maxDuration = 300 @@ -153,7 +153,10 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw } } - const explorerEnabled = await isExplorerEnabled(trustedUserEmail(claims?.email)) + const [explorerEnabled, useStatusPageWidget] = await Promise.all([ + isExplorerEnabled(trustedUserEmail(claims?.email)), + isServerFlagEnabled('incidentIoStatusPage'), + ]) const envThrottled = process.env.IS_THROTTLED !== 'false' @@ -192,6 +195,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw baseUrl: getURL(), supportMode, isExplorerEnabled: explorerEnabled, + useStatusPageWidget, signal: abortController.signal, })