Files
supabase/apps/studio/lib/ai/tools/notebook-tools.ts
Charis 9be60cab63 refactor(studio): add optimistic locking to update_notebook (#49111)
## 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?

Refactor / hardening

## What is the current behavior?

The `update_notebook` AI tool re-fetches the notebook right before
applying operations, but concurrent edits are last-write-wins: the model
has no way to detect that the notebook changed since it planned the
edit, so a stale diff can silently overwrite someone else's changes.

## What is the new behavior?

- `get_notebook` now returns the notebook's `updated_at` timestamp.
- `update_notebook` requires a new `expected_updated_at` input field
(the `updated_at` the model got from `get_notebook`).
- At execute time, after the existing re-fetch and before applying
operations, `update_notebook` compares the fetched `updated_at` against
`expected_updated_at` and throws a descriptive error if they don't
match, telling the model to re-read the notebook and reissue the update.
- The notebook system prompt and mock tools (used by the eval harness)
are updated to match.

## Additional context

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

## Summary by CodeRabbit

* **New Features**
  * Notebook retrieval now includes the latest update timestamp.
* Notebook edits require confirmation that the content is current before
saving.

* **Bug Fixes**
  * Prevented stale edits from overwriting newer notebook changes.
* Conflicting updates are rejected, allowing the latest content to be
fetched before retrying.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-14 13:20:27 -04:00

234 lines
8.9 KiB
TypeScript

import { acceptUntrustedSql, untrustedSql } from '@supabase/pg-meta'
import { tool } from 'ai'
import { z } from 'zod'
import { getContent } from '@/data/content/content-infinite-query'
import {
applyNotebookOperations,
describeNotebookOperationError,
notebookOperationsSchema,
} from '@/data/content/notebooks/notebook-operations'
import { getNotebook } from '@/data/content/notebooks/notebook-query'
import {
agentNotebookSchema,
type CellWire,
type NotebookWire,
type WritableCell,
type WritableNotebook,
} from '@/data/content/notebooks/notebook-schema'
import { createNotebook, updateNotebook } from '@/data/content/notebooks/notebook-upsert-mutation'
import { acceptUntrustedLogsSql, untrustedLogSql } from '@/data/logs/safe-analytics-sql'
import type { Notebooks } from '@/types'
export type NotebookToolsContext = {
projectRef?: string
authorization?: string
}
export const getNotebookTools = (ctx: NotebookToolsContext = {}) => {
const { projectRef, authorization } = ctx
const authHeaders = authorization ? { Authorization: authorization } : undefined
return {
list_notebooks: tool({
description: 'List the notebooks saved for this project',
inputSchema: z.object({
cursor: z
.string()
.optional()
.describe('Cursor from a previous call, used to fetch the next page.'),
limit: z
.number()
.int()
.positive()
.max(100)
.default(20)
.describe('Max number of notebooks to return.'),
sort_by: z
.enum(['name', 'inserted_at'])
.optional()
.describe(
'Field to sort notebooks by. There is no "updated_at" sort — use "inserted_at" for creation order.'
),
}),
execute: async ({ cursor, limit, sort_by }) => {
const { content, cursor: nextCursor } = await getContent(
{ projectRef, type: 'notebook', limit, cursor, sort: sort_by },
undefined,
authHeaders
)
return {
notebooks: content.map((notebook) => ({
id: notebook.id,
name: notebook.name,
description: notebook.description,
visibility: notebook.visibility,
updated_at: notebook.updated_at,
cell_count: (notebook.content as Notebooks.Content).cells.length,
})),
cursor: nextCursor,
}
},
}),
get_notebook: tool({
description:
'Get a single notebook by id, including the markdown text and resolved SQL of every cell.',
inputSchema: z.object({
id: z.string().describe('The id of the notebook to fetch.'),
}),
execute: async ({ id }) => {
const notebook = await getNotebook({ projectRef, id }, undefined, authHeaders)
return {
id: notebook.id,
name: notebook.name,
description: notebook.description,
visibility: notebook.visibility,
updated_at: notebook.updated_at,
// Inlined rather than a shared helper: this discards the `unchecked_sql` brand for
// display purposes only — the result is returned to the agent, never written back.
cells: notebook.content.cells.map((cell) => {
switch (cell._tag) {
case 'markdown_cell':
return cell
case 'database_cell': {
const { unchecked_sql, ...rest } = cell
return { ...rest, sql: unchecked_sql }
}
case 'log_cell': {
const { unchecked_sql, ...rest } = cell
return { ...rest, sql: unchecked_sql }
}
}
}),
}
},
}),
create_notebook: tool({
description:
'Asks the user to create a new notebook with the given cells. Requires user approval before creating.',
inputSchema: z.object({
name: z.string().describe('A short, descriptive name for the notebook.'),
description: z
.string()
.optional()
.describe('A short description of what the notebook is for.'),
content: agentNotebookSchema.describe(
'The notebook content: a schema version and an ordered list of cells (markdown, database, or log). Cells must not include an id — one is assigned when the notebook is saved.'
),
}),
needsApproval: true,
execute: async ({ name, description, content }) => {
// This approval gate is the user gesture that promotes each cell's SQL from
// untrusted to safe — keep the promotion here, not in a shared helper, so it's
// auditable directly alongside the `needsApproval: true` above.
const cells: WritableNotebook['cells'] = content.cells.map((cell): WritableCell => {
switch (cell._tag) {
case 'markdown_cell':
return cell
case 'database_cell':
return { ...cell, sql: acceptUntrustedSql(untrustedSql(cell.sql)) }
case 'log_cell':
return { ...cell, sql: acceptUntrustedLogsSql(untrustedLogSql(cell.sql)) }
}
})
const result = await createNotebook(
{
projectRef: projectRef ?? '',
name,
description,
content: { schema_version: content.schema_version, cells },
},
undefined,
authHeaders
)
return { id: result.id, name }
},
}),
update_notebook: tool({
description:
'Asks the user to apply an ordered list of cell operations (insert, replace, delete, move) to an existing notebook. Requires user approval before updating. Re-fetches the notebook right before applying the operations and rejects the update if it changed since expected_updated_at.',
inputSchema: z.object({
id: z.string().describe('The id of the notebook to update.'),
expected_updated_at: z
.string()
.describe(
'The `updated_at` you received from `get_notebook`. The update is rejected if the notebook changed since.'
),
operations: notebookOperationsSchema.describe(
'An ordered list of operations to apply to the notebook, addressing existing cells by id.'
),
}),
needsApproval: true,
execute: async ({ id, expected_updated_at, operations }) => {
const notebook = await getNotebook({ projectRef, id }, undefined, authHeaders)
if (notebook.updated_at !== expected_updated_at) {
throw new Error(
`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.`
)
}
// Inlined rather than a shared helper, right beside this tool's own
// `needsApproval: true`: this discards each cell's `unchecked_sql` brand so
// applyNotebookOperations can splice cells as plain data. The result is never
// written or executed as-is — every cell is re-promoted via
// acceptUntrustedSql/acceptUntrustedLogsSql further down in this same execute,
// right before the PUT.
const wireNotebook: NotebookWire = {
schema_version: notebook.content.schema_version,
cells: notebook.content.cells.map((cell): CellWire => {
switch (cell._tag) {
case 'markdown_cell':
return cell
case 'database_cell': {
const { unchecked_sql, ...rest } = cell
return { ...rest, sql: unchecked_sql }
}
case 'log_cell': {
const { unchecked_sql, ...rest } = cell
return { ...rest, sql: unchecked_sql }
}
}
}),
}
const result = applyNotebookOperations(wireNotebook, operations)
if (!result.success) {
throw new Error(describeNotebookOperationError(result.error))
}
// Same promotion as create_notebook above, inlined here for the same auditability
// reason: it must stay visible next to this tool's own `needsApproval: true`.
const cells: WritableNotebook['cells'] = result.notebook.cells.map((cell): WritableCell => {
switch (cell._tag) {
case 'markdown_cell':
return cell
case 'database_cell':
return { ...cell, sql: acceptUntrustedSql(untrustedSql(cell.sql)) }
case 'log_cell':
return { ...cell, sql: acceptUntrustedLogsSql(untrustedLogSql(cell.sql)) }
}
})
await updateNotebook(
{
projectRef: projectRef ?? '',
id,
name: notebook.name,
description: notebook.description,
content: { schema_version: result.notebook.schema_version, cells },
},
undefined,
authHeaders
)
return { id, name: notebook.name }
},
}),
}
}