Files
Charis 21511042a3 feat(studio): assistant logs context and reports guard (#48514)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Feature — final PR (9/9) of the SQL editor logs-source stack.

**Base branch:** `charislam/sql-editor-inline-ai-clickhouse-dialect` (PR
8). Nothing here is user-visible: entry points stay behind
`sqlEditorLogsSource` + `otelLegacyLogs`, and flag rollout happens after
the whole stack merges.

## What is the current behavior?

- The Assistant has no idea a SQL editor snippet targets the logs
backend. Ask it about a logs snippet and it answers in Postgres, because
the attached query is fenced as ` ```sql ` and nothing tells the model
otherwise.
- Because the `sql` fence is what `MessageMarkdown` treats as runnable
Postgres, an attached ClickHouse query is rendered with a
Run-against-Postgres affordance and branded with `untrustedSql`.
- "Debug with Assistant" on a failed logs query produces a dialect-less
prompt, so both the in-app assistant and the copyable version get
debugged as Postgres.
- A report referencing a `log_sql` snippet runs its ClickHouse SQL
against the user's Postgres database and surfaces the resulting error.

## What is the new behavior?

**Assistant panel.** The "Current Query" chip records which backend the
attached query targets. That reaches the model two ways: each attachment
is fenced with its own dialect (` ```clickhouse ` vs ` ```sql `), and a
`containsLogsSnippets` flag rides on the user message as AI SDK
`metadata`. The server reads the flag off the conversation and prepends
the ClickHouse dialect rules plus the logs schema reference as a
non-cached context message.

Two design points worth calling out in review:

- The flag lives on the **message**, not the request body, so Retry and
the tool-approval continuation reproduce the context a message was
originally asked in — neither of those passes a per-call body.
- It's derived from **what's actually attached**, so detaching the chip
drops the claim rather than leaving the two able to disagree.

The `clickhouse` fence also keeps a logs query out of
`MessageMarkdown`'s `sql` branch, so it's no longer offered as runnable
Postgres or branded with `untrustedSql` — a boundary this stack's
distinct brands exist to prevent crossing.

**Debug flow.** `buildDebugChatArgs` attaches its query with a source
for the same reason, and names the dialect in the prompt text so the
copyable version stands on its own outside the app.

**Reports.** A report only stores a snippet id, so whether it queries
the logs backend is only knowable once the content loads. `ReportBlock`
guards on the fetched type and renders a `LogsSnippetReportBlock`
placeholder instead of executing. Double-guarded: no `sql` for a logs
snippet (so it's out of the query key and `queryFn` short-circuits even
on an explicit `refetch`) and `enabled` excludes it.

**Incidental cleanups.** `buildAssistantContextMessages` extracted out
of `generate-assistant-response`; a schema-access sentinel that was
duplicated as a string literal across two files (and compared against)
replaced with one exported constant; `SqlSnippet` deduplicated to a
single declaration; `resolveSnippetSource` / `isLogsSource` shared
instead of re-implemented per surface.

**Tests.** 4 new/extended suites. Notable cases pinned: a message with
no metadata must validate (`safeValidateUIMessages` applies
`metadataSchema` to *every* message, so a required schema would 400
every existing conversation); only *user* messages count, so a model
reply can't talk the server into a different dialect; a mixed-attachment
message is flagged without overclaiming a single source; and
`ReportBlock` registers no pg-meta mock for the logs cases, so an
unhandled request failing the test *is* the assertion that logs SQL
never reaches Postgres.

Verified: `pnpm typecheck`, `lint:ratchet` (no regression), Prettier,
and the full Studio suite (459 files / 4969 tests).

## Additional context

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Added support for recognizing log snippets in reports, with clear
guidance to open them in the SQL editor or remove them.
- AI Assistant now understands log snippets and provides
ClickHouse-specific context, formatting, and troubleshooting guidance.
- Snippets retain their source information when shared with the AI
Assistant.

- **Bug Fixes**
- Prevented unsupported log snippets from being executed as regular
database queries.
  - Improved source detection when opening snippets directly from links.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-04 09:02:40 -04:00

205 lines
7.1 KiB
TypeScript

import { isToolUIPart, type UIMessage } from 'ai'
import { toast } from 'sonner'
import { SAFE_FUNCTIONS } from './AiAssistant.constants'
import {
isLogsSource,
sqlSourceToFenceLanguage,
} from '@/components/interfaces/SQLEditor/querySource'
import { authKeys } from '@/data/auth/keys'
import { databaseExtensionsKeys } from '@/data/database-extensions/keys'
import { databaseIndexesKeys } from '@/data/database-indexes/keys'
import { databasePoliciesKeys } from '@/data/database-policies/keys'
import { databaseTriggerKeys } from '@/data/database-triggers/keys'
import { databaseKeys } from '@/data/database/keys'
import { enumeratedTypesKeys } from '@/data/enumerated-types/keys'
import { handleError } from '@/data/fetchers'
import { tableKeys } from '@/data/tables/keys'
import { tryParseJson } from '@/lib/helpers'
import type { SqlSnippet } from '@/state/ai-assistant-state'
import { ResponseError } from '@/types'
export type MutationCategory = 'functions' | 'rls-policies'
// [Joshen] This is just very basic identification, but possible can extend perhaps
export const identifyQueryType = (query: string): MutationCategory | undefined => {
const formattedQuery = query.toLowerCase().replaceAll('\n', ' ')
if (
formattedQuery.includes('create function') ||
formattedQuery.includes('create or replace function')
) {
return 'functions'
} else if (formattedQuery.includes('create policy') || formattedQuery.includes('alter policy')) {
return 'rls-policies'
}
return undefined
}
// Check for function calls that aren't in the safe list
/** @deprecated [Joshen] Ideally we move away from this as this isn't a scalable way to deduce */
export const containsUnknownFunction = (query: string) => {
const normalizedQuery = query.trim().toLowerCase()
const functionCallRegex = /\w+\s*\(/g
const functionCalls = normalizedQuery.match(functionCallRegex) || []
return functionCalls.some((func) => {
const isReadOnlyFunc = SAFE_FUNCTIONS.some((safeFunc) => func.trim().toLowerCase() === safeFunc)
return !isReadOnlyFunc
})
}
/** @deprecated
* [Joshen] This isn't really a scalable way to reduce this behaviour, we now have support
* for a readonly connection string which we can use this to run queries, and is a much
* clearer way to deduce if the query is read only or not
*/
export const isReadOnlySelect = (query: string): boolean => {
const normalizedQuery = query.trim().toLowerCase()
// Check if it starts with SELECT
if (!normalizedQuery.startsWith('select')) return false
// List of keywords that indicate write operations
const writeOperations = ['insert', 'update', 'delete', 'alter', 'drop', 'create', 'replace']
// Words that may appear in column names etc
const allowedPatterns = ['created', 'inserted', 'updated', 'deleted', 'truncate']
// Check for any write operations
const hasWriteOperation = writeOperations.some((op) => {
// Ignore if part of allowed pattern
const isAllowed = allowedPatterns.some(
(allowed) => normalizedQuery.includes(allowed) && allowed.includes(op)
)
return !isAllowed && normalizedQuery.includes(op)
})
if (hasWriteOperation) return false
const hasUnknownFunction = containsUnknownFunction(normalizedQuery)
if (hasUnknownFunction) return false
return true
}
export const hasPendingToolApproval = (messages: Pick<UIMessage, 'role' | 'parts'>[]) => {
return messages.some((message) => {
if (message.role !== 'assistant') return false
return message.parts?.some((part) => isToolUIPart(part) && part.state === 'approval-requested')
})
}
export const resolvePendingToolApprovalsAsDenied = (messages: UIMessage[]): UIMessage[] => {
return messages.map((message) => {
if (message.role !== 'assistant') return message
const parts = message.parts?.map((part) => {
if (!isToolUIPart(part) || part.state !== 'approval-requested') return part
return {
...part,
state: 'output-denied',
approval: {
id: part.approval.id,
approved: false,
reason: 'Skipped because the user sent a follow-up message.',
},
} as UIMessage['parts'][number]
})
return { ...message, parts } as UIMessage
})
}
const getContextKey = (pathname: string) => {
const [, , , ...rest] = pathname.split('/')
const key = rest.join('/')
return key
}
export const getContextualInvalidationKeys = ({
ref,
pathname,
schema = 'public',
}: {
ref: string
pathname: string
schema?: string
}) => {
const key = getContextKey(pathname)
return (
(
{
'auth/users': [authKeys.usersInfinite(ref)],
'database/policies': [databasePoliciesKeys.list(ref)],
'database/functions': [databaseKeys.databaseFunctions(ref)],
'database/tables': [
tableKeys.list(ref, schema, { includeColumns: true }),
tableKeys.list(ref, schema, { includeColumns: false }),
],
'database/triggers': [databaseTriggerKeys.list(ref)],
'database/types': [enumeratedTypesKeys.list(ref)],
'database/extensions': [databaseExtensionsKeys.list(ref)],
'database/indexes': [databaseIndexesKeys.list(ref, schema)],
} as const
)[key] ?? []
)
}
export const onErrorChat = (error: Error) => {
const parsedError = error ? tryParseJson(error.message) : undefined
try {
handleError(parsedError?.error || parsedError || error)
} catch (e: any) {
if (e instanceof ResponseError) {
toast.error(e.message)
} else if (e instanceof Error) {
toast.error(e.message)
} else if (typeof e === 'string') {
toast.error(e)
} else {
toast.error('An unknown error occurred')
}
}
}
export function containsLogsSnippets(snippets: readonly SqlSnippet[] | undefined): boolean {
return (snippets ?? []).some(
(snippet) => typeof snippet !== 'string' && isLogsSource(snippet.source)
)
}
export const getSnippetLabel = (snippet: SqlSnippet, index: number): string =>
typeof snippet === 'string' ? `Snippet ${index + 1}` : snippet.label
export const getSnippetContent = (snippet: SqlSnippet): string =>
typeof snippet === 'string' ? snippet : snippet.content
/**
* The fence language an attached query is written into the message with. A logs query
* is fenced as `clickhouse` so the model can tell which attachment is ClickHouse
* against the `logs` table — a single message can carry both dialects.
*
* It also keeps the two apart in the rendered message: MessageMarkdown treats a `sql`
* fence as runnable Postgres (`DisplayBlockRenderer`, branded with `untrustedSql`),
* which a ClickHouse query must never be offered as.
*/
function getSnippetFenceLanguage(snippet: SqlSnippet): 'sql' | 'clickhouse' {
return sqlSourceToFenceLanguage(typeof snippet === 'string' ? undefined : snippet.source)
}
/**
* Renders attached queries as the fenced code blocks appended to the message text,
* each labelled with its own dialect.
*/
export function formatAttachedSnippets(snippets: readonly SqlSnippet[]): string {
return snippets
.map(
(snippet) =>
'```' + getSnippetFenceLanguage(snippet) + '\n' + getSnippetContent(snippet) + '\n```'
)
.join('\n')
}