mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 11:25:06 +03:00
## 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 -->
234 lines
8.9 KiB
TypeScript
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 }
|
|
},
|
|
}),
|
|
}
|
|
}
|