mirror of
https://github.com/supabase/supabase.git
synced 2026-11-06 20:38:12 +08:00
## Summary Migrates the create, delete, and run notebook assistant tools to the tool contract + server pattern used by `update_notebook`. Each tool now defines its schema once in a shared contract, with server-only execution split into a `.server.ts` module. This removes duplicated client-side schemas and gives the three tools one shared notebook mutation output schema. ## Changes - Add `create-notebook`, `delete-notebook`, and `run-notebook` tool contracts, each with a `.server.ts` implementation. - Remove the duplicated client-side schemas for these tools from `notebook-tools.ts` and the registry. - Add `notebook-mutation-output.ts` as the single output schema shared by the notebook mutation tools. - Add `lib/ai/opt-in-level.ts` with the opt-in level helpers, extracted from the existing module to break an import cycle. - Add optional `ToolContract.outputOnInvalid` so `run_notebook` history replay can reuse a stored output when input fails validation. - Update the AI assistant panel renderers (proposal, run, opt-in modal and request) and the tool filter, sanitizer, and cache invalidation to use the new contracts. ## Deviations worth reviewing - **`outputOnInvalid` on `ToolContract`.** This is a new optional hook. It exists so `run_notebook` history replay can return a valid output when stored input no longer passes validation. Reviewers should confirm this is the right level for the hook rather than handling it inside the tool. - **`opt-in-level.ts` extraction.** The helpers were moved into their own module to break a circular import between the tool modules and the opt-in components. This adds one file, but no behavior change is intended. - **`create-notebook` contract imports the data-layer `notebook-schema`.** This matches `update-notebook`. It should be moved behind the contract before the planned ESLint rule step, which restricts contract modules from importing data-layer code. ## Testing - `vitest` for `lib/ai`, `components/ui/AIAssistantPanel`, and `state`: 920 tests pass across 71 files. - `tsc`: one error remains in `packages/ui-patterns` (`InstructionBlocks.tsx`), which is unrelated to this change. I have not verified whether it also fails on the base branch. - ESLint reports no errors on the changed files (9 `no-explicit-any` warnings in `tool-filter.ts` and its test); Prettier is clean. - Not run: knip and the lint ratchet.
154 lines
5.9 KiB
TypeScript
154 lines
5.9 KiB
TypeScript
import type { JSONValue } from 'ai'
|
|
|
|
import { getAuthHeaders } from './auth-headers'
|
|
import type { NotebookRunCellOutput, NotebookRunOutput } from './notebook-run-output'
|
|
import { NotebookToolError } from './notebook-tool-error'
|
|
import { contract } from './run-notebook'
|
|
import { approvalGatedToolFromContract } from './tool-from-contract'
|
|
import type { ApprovalGatedToolFactory } from './types'
|
|
import { resolveLogTimeRange } from '@/components/interfaces/QuerySources/LogTimeRange.utils'
|
|
import { getNotebook } from '@/data/content/notebooks/notebook-query'
|
|
import { executeLogsSql } from '@/data/logs/execute-logs-sql-mutation'
|
|
import { QUERY_SOURCE_REGISTRY } from '@/data/query-sources/query-source-registry'
|
|
import { getReadReplicas } from '@/data/read-replicas/replicas-query'
|
|
import { executeSql } from '@/data/sql/execute-sql-mutation'
|
|
import { applyAutoLimit } from '@/data/sql/utils'
|
|
|
|
function getErrorMessage(error: unknown): string {
|
|
if (error instanceof Error) return error.message
|
|
if (typeof error === 'object' && error !== null && 'message' in error) {
|
|
const message = (error as { message?: unknown }).message
|
|
if (typeof message === 'string') return message
|
|
}
|
|
return 'An unexpected error occurred while running this query.'
|
|
}
|
|
|
|
export const createTool: ApprovalGatedToolFactory<typeof contract> = (ctx) => {
|
|
const { projectRef, connectionString, aiOptInLevel = 'schema' } = ctx
|
|
const authHeaders = getAuthHeaders(ctx.authorization)
|
|
|
|
return approvalGatedToolFromContract(
|
|
contract,
|
|
ctx,
|
|
async ({ id, expected_updated_at }, { abortSignal }, gate) => {
|
|
const notebook = await getNotebook({ projectRef, id }, abortSignal, authHeaders)
|
|
|
|
if (notebook.updated_at !== expected_updated_at) {
|
|
throw new NotebookToolError(
|
|
`Notebook "${id}" changed since expected_updated_at (${expected_updated_at}); it is now ${notebook.updated_at}. Call get_notebook again and reissue run_notebook against the current content.`,
|
|
{ exposeToAssistant: true }
|
|
)
|
|
}
|
|
|
|
const queryCells = notebook.content.cells.filter(
|
|
(cell) => cell._tag === 'database_cell' || cell._tag === 'log_cell'
|
|
)
|
|
const shouldLookupReplica = queryCells.some(
|
|
(cell) =>
|
|
cell._tag === 'database_cell' &&
|
|
cell.database_identifier !== undefined &&
|
|
cell.database_identifier !== projectRef
|
|
)
|
|
let databases: Awaited<ReturnType<typeof getReadReplicas>> = []
|
|
let replicaLookupFailure: { error: unknown } | undefined
|
|
if (shouldLookupReplica) {
|
|
try {
|
|
databases = await getReadReplicas({ projectRef }, abortSignal, authHeaders)
|
|
} catch (error) {
|
|
replicaLookupFailure = { error }
|
|
}
|
|
}
|
|
|
|
const cells: NotebookRunCellOutput[] = []
|
|
|
|
// This execute function only runs after the AI SDK approval gate resolves true. That
|
|
// approval is the explicit user gesture which promotes every persisted cell's
|
|
// untrusted SQL immediately before execution. Run sequentially to preserve notebook
|
|
// order and avoid racing cells that may depend on earlier writes.
|
|
for (const cell of queryCells) {
|
|
const title = cell.title?.trim() || 'Untitled query'
|
|
|
|
try {
|
|
if (cell._tag === 'log_cell') {
|
|
const result = await executeLogsSql({
|
|
projectRef: projectRef ?? '',
|
|
sql: gate.acceptUntrustedLogsSql(cell.unchecked_sql),
|
|
range: resolveLogTimeRange(cell.time_range),
|
|
endpoint: QUERY_SOURCE_REGISTRY.logs.endpoint,
|
|
signal: abortSignal,
|
|
headers: authHeaders,
|
|
})
|
|
if (result.error) throw result.error
|
|
cells.push({
|
|
cell_id: cell._id,
|
|
title,
|
|
source: 'logs',
|
|
status: 'success',
|
|
// The analytics response crossed a JSON fetch boundary, although its shared
|
|
// data-layer type remains `unknown[]` because individual endpoints vary.
|
|
rows: result.rows as JSONValue[],
|
|
})
|
|
continue
|
|
}
|
|
|
|
const isReplicaTarget =
|
|
cell.database_identifier !== undefined && cell.database_identifier !== projectRef
|
|
|
|
if (isReplicaTarget && replicaLookupFailure !== undefined) {
|
|
throw replicaLookupFailure.error
|
|
}
|
|
|
|
const selectedConnectionString = !isReplicaTarget
|
|
? connectionString
|
|
: databases?.find((database) => database.identifier === cell.database_identifier)
|
|
?.connectionString
|
|
|
|
if (!selectedConnectionString) {
|
|
throw new Error(
|
|
`Unable to run query: connection string is missing for database "${cell.database_identifier ?? projectRef ?? 'primary'}"`
|
|
)
|
|
}
|
|
|
|
const limitedSql = applyAutoLimit(
|
|
gate.acceptUntrustedSql(cell.unchecked_sql),
|
|
cell.row_limit
|
|
)
|
|
const { result } = await executeSql<JSONValue[]>(
|
|
{
|
|
projectRef,
|
|
connectionString: selectedConnectionString,
|
|
sql: limitedSql.sql,
|
|
isStatementTimeoutDisabled: true,
|
|
},
|
|
abortSignal,
|
|
authHeaders
|
|
)
|
|
cells.push({
|
|
cell_id: cell._id,
|
|
title,
|
|
source: 'database',
|
|
status: 'success',
|
|
rows: Array.isArray(result) ? result : [],
|
|
})
|
|
} catch (error) {
|
|
cells.push({
|
|
cell_id: cell._id,
|
|
title,
|
|
source: cell._tag === 'log_cell' ? 'logs' : 'database',
|
|
status: 'error',
|
|
error: { message: getErrorMessage(error) },
|
|
})
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: notebook.id,
|
|
name: notebook.name,
|
|
updated_at: notebook.updated_at,
|
|
optInLevel: aiOptInLevel,
|
|
cells,
|
|
} satisfies NotebookRunOutput
|
|
}
|
|
)
|
|
}
|