mirror of
https://github.com/supabase/supabase.git
synced 2026-10-17 07:09:07 +03:00
## Summary Each assistant tool's identity and contract is currently written in up to nine places that nothing ties together at compile time. This PR adds a contract/server split and migrates `update_notebook`, the tool with the most copies, as the first proof of the design. - **Contract** (`lib/ai/tools/update-notebook.ts`): isomorphic file with the input and output schemas, approval flag, minimum opt-in level, and `toModelOutput`, the single rule for what the model may see. - **Server** (`update-notebook.server.ts`): the executable tool, built with `approvalGatedToolFromContract`. - **Registry** (`registry.ts`): collects contracts, derives a typed `StudioUIMessage`, and provides `sanitizeContractPart` for history replay. - **Shared types** (`types.ts`): `ToolContract`, `ToolContext`, `ToolFactory`, plus a single `getAuthHeaders` replacing three variants. - **Approval gate** (`approval-gate.ts`): approval-gated tools get `acceptUntrustedSql` / `acceptUntrustedLogsSql` through a gate passed to `execute`, and `*.server.ts` files can't import the raw functions (ESLint). - **Opt-in level**: `update_notebook` is gated by its contract's `minOptInLevel`. `TOOL_CATEGORY_MAP` is now typed against `ToolName` and excludes migrated tools, which exposed `get_active_incidents` missing from the name enum. Removed duplicates: the `update_notebook` sanitizer in `tool-sanitizer.ts`, the client-side `stripNotebookSnapshot`, and the copied update schemas in `Message.utils.ts`. ## Behavior changes - The client no longer strips `previous_content` from `update_notebook` outputs before sending history, so requests carry the previous notebook snapshot. The server still strips it before the model sees it. - On history replay, an `update_notebook` output that doesn't match its schema is dropped instead of passed through minus `previous_content`. ## Testing - New `registry.test.ts` (typed parts, replay sanitizer) and a contract case in `tool-filter.test.ts`. - Studio typecheck, lint ratchet, knip, Prettier and the affected vitest suites pass.
98 lines
3.8 KiB
TypeScript
98 lines
3.8 KiB
TypeScript
import { untrustedSql } from '@supabase/pg-meta'
|
|
|
|
import { getAuthHeaders } from './auth-headers'
|
|
import { assertValidDatabaseIdentifiers, NotebookToolError } from './notebook-tool-error'
|
|
import { approvalGatedToolFromContract } from './tool-from-contract'
|
|
import type { ApprovalGatedToolFactory } from './types'
|
|
import { contract } from './update-notebook'
|
|
import {
|
|
applyNotebookOperations,
|
|
describeNotebookOperationError,
|
|
} from '@/data/content/notebooks/notebook-operations'
|
|
import { getNotebook } from '@/data/content/notebooks/notebook-query'
|
|
import {
|
|
toWireNotebook,
|
|
type WritableCell,
|
|
type WritableNotebook,
|
|
} from '@/data/content/notebooks/notebook-schema'
|
|
import { upsertNotebook } from '@/data/content/notebooks/notebook-upsert-mutation'
|
|
import { untrustedLogSql } from '@/data/logs/safe-analytics-sql'
|
|
import { getReadReplicas } from '@/data/read-replicas/replicas-query'
|
|
|
|
export const createTool: ApprovalGatedToolFactory<typeof contract> = (ctx) => {
|
|
const { projectRef } = ctx
|
|
const authHeaders = getAuthHeaders(ctx.authorization)
|
|
|
|
return approvalGatedToolFromContract(
|
|
contract,
|
|
ctx,
|
|
async ({ id, expected_updated_at, operations }, { abortSignal }, gate) => {
|
|
const newCells = operations.flatMap((operation) =>
|
|
operation._tag === 'insert_cell' || operation._tag === 'replace_cell'
|
|
? [operation.cell]
|
|
: []
|
|
)
|
|
if (
|
|
newCells.some(
|
|
(cell) => cell._tag === 'database_cell' && cell.database_identifier !== undefined
|
|
)
|
|
) {
|
|
const databases = await getReadReplicas({ projectRef }, abortSignal, authHeaders)
|
|
assertValidDatabaseIdentifiers(
|
|
newCells,
|
|
new Set((databases ?? []).map((database) => database.identifier))
|
|
)
|
|
}
|
|
|
|
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 update_notebook against the current content.`,
|
|
{ exposeToAssistant: true }
|
|
)
|
|
}
|
|
|
|
// The result of applyNotebookOperations is never written or executed as-is — every
|
|
// cell is re-promoted via gate.acceptUntrustedSql/gate.acceptUntrustedLogsSql further
|
|
// down in this same execute, right before the PUT.
|
|
const wireNotebook = toWireNotebook(notebook.content)
|
|
|
|
const result = applyNotebookOperations(wireNotebook, operations)
|
|
if (!result.success) {
|
|
throw new NotebookToolError(describeNotebookOperationError(result.error), {
|
|
exposeToAssistant: true,
|
|
})
|
|
}
|
|
|
|
// Same promotion as create_notebook, inlined here for the same auditability reason: it
|
|
// must stay visible in the function the approval gate guards. `gate` is only handed to
|
|
// an `execute` that runs after the user approves.
|
|
const cells: WritableNotebook['cells'] = result.notebook.cells.map((cell): WritableCell => {
|
|
switch (cell._tag) {
|
|
case 'markdown_cell':
|
|
return cell
|
|
case 'database_cell':
|
|
return { ...cell, sql: gate.acceptUntrustedSql(untrustedSql(cell.sql)) }
|
|
case 'log_cell':
|
|
return { ...cell, sql: gate.acceptUntrustedLogsSql(untrustedLogSql(cell.sql)) }
|
|
}
|
|
})
|
|
|
|
await upsertNotebook(
|
|
{
|
|
projectRef: projectRef ?? '',
|
|
id,
|
|
name: notebook.name,
|
|
description: notebook.description ?? undefined,
|
|
content: { schema_version: result.notebook.schema_version, cells },
|
|
},
|
|
abortSignal,
|
|
authHeaders
|
|
)
|
|
|
|
return { id, name: notebook.name, previous_content: wireNotebook }
|
|
}
|
|
)
|
|
}
|