Files
supabase/apps/studio/lib/ai/tools/update-notebook.server.ts
T
Charis cd6afdd8b2 refactor(studio): add tool contracts and migrate update_notebook (#51626)
## 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.
2026-10-14 10:44:38 -04:00

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 }
}
)
}