table tools

This commit is contained in:
Siddharth Ganesan
2026-02-24 14:32:55 -08:00
parent 3de3ef4786
commit 724aaa1432
10 changed files with 528 additions and 19 deletions

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Command } from 'cmdk'
import { Database, HelpCircle, Layout, Settings } from 'lucide-react'
import { Database, HelpCircle, Layout, Settings, Table } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { createPortal } from 'react-dom'
import { Library } from '@/components/emcn'
@@ -119,14 +119,13 @@ export function SearchModal({
href: `/workspace/${workspaceId}/knowledge`,
hidden: permissionConfig.hideKnowledgeBaseTab,
},
// TODO: Uncomment when working on tables
// {
// id: 'tables',
// name: 'Tables',
// icon: Table,
// href: `/workspace/${workspaceId}/tables`,
// hidden: permissionConfig.hideTablesTab,
// },
{
id: 'tables',
name: 'Tables',
icon: Table,
href: `/workspace/${workspaceId}/tables`,
hidden: permissionConfig.hideTablesTab,
},
{
id: 'help',
name: 'Help',

View File

@@ -2,7 +2,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Database, HelpCircle, Layout, MessageSquare, Plus, Search, Settings } from 'lucide-react'
import { Database, HelpCircle, Layout, MessageSquare, Plus, Search, Settings, Table } from 'lucide-react'
import Link from 'next/link'
import { useParams, usePathname, useRouter } from 'next/navigation'
import { Button, Download, FolderPlus, Library, Loader, Tooltip } from '@/components/emcn'
@@ -274,14 +274,13 @@ export const Sidebar = memo(function Sidebar() {
href: `/workspace/${workspaceId}/knowledge`,
hidden: permissionConfig.hideKnowledgeBaseTab,
},
// TODO: Uncomment when working on tables
// {
// id: 'tables',
// label: 'Tables',
// icon: Table,
// href: `/workspace/${workspaceId}/tables`,
// hidden: permissionConfig.hideTablesTab,
// },
{
id: 'tables',
label: 'Tables',
icon: Table,
href: `/workspace/${workspaceId}/tables`,
hidden: permissionConfig.hideTablesTab,
},
{
id: 'help',
label: 'Help',

View File

@@ -340,6 +340,7 @@ const SERVER_TOOLS = new Set<string>([
'set_environment_variables',
'make_api_request',
'knowledge_base',
'user_table',
])
const SIM_WORKFLOW_TOOL_HANDLERS: Record<
@@ -497,7 +498,10 @@ async function executeServerToolDirect(
enrichedParams.workflowId = context.workflowId
}
const result = await routeExecution(toolName, enrichedParams, { userId: context.userId })
const result = await routeExecution(toolName, enrichedParams, {
userId: context.userId,
workspaceId: context.workspaceId,
})
return { success: true, output: result }
} catch (error) {
logger.error('Server tool execution failed', {

View File

@@ -624,6 +624,21 @@ Supports full and partial execution:
},
annotations: { destructiveHint: false },
},
{
name: 'sim_table',
agentId: 'table',
description:
'Manage user-defined tables for structured data storage. Supports creating tables with typed schemas, inserting/updating/deleting rows, querying with filters and sorting, and batch operations.',
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
context: { type: 'object' },
},
required: ['request'],
},
annotations: { destructiveHint: false },
},
{
name: 'sim_custom_tool',
agentId: 'custom_tool',

View File

@@ -2,6 +2,7 @@ import type { z } from 'zod'
export interface ServerToolContext {
userId: string
workspaceId?: string
}
/**

View File

@@ -4,6 +4,7 @@ import { getBlocksMetadataServerTool } from '@/lib/copilot/tools/server/blocks/g
import { getTriggerBlocksServerTool } from '@/lib/copilot/tools/server/blocks/get-trigger-blocks'
import { searchDocumentationServerTool } from '@/lib/copilot/tools/server/docs/search-documentation'
import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base'
import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table'
import { makeApiRequestServerTool } from '@/lib/copilot/tools/server/other/make-api-request'
import { searchOnlineServerTool } from '@/lib/copilot/tools/server/other/search-online'
import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials'
@@ -29,6 +30,7 @@ const serverToolRegistry: Record<string, BaseServerTool> = {
[getCredentialsServerTool.name]: getCredentialsServerTool,
[makeApiRequestServerTool.name]: makeApiRequestServerTool,
[knowledgeBaseServerTool.name]: knowledgeBaseServerTool,
[userTableServerTool.name]: userTableServerTool,
}
/**

View File

@@ -0,0 +1,371 @@
import { createLogger } from '@sim/logger'
import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool'
import type { UserTableArgs, UserTableResult } from '@/lib/copilot/tools/shared/schemas'
import {
createTable,
deleteTable,
getTableById,
listTables,
insertRow,
batchInsertRows,
getRowById,
queryRows,
updateRow,
deleteRow,
updateRowsByFilter,
deleteRowsByFilter,
} from '@/lib/table/service'
const logger = createLogger('UserTableServerTool')
export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> = {
name: 'user_table',
async execute(
params: UserTableArgs,
context?: ServerToolContext
): Promise<UserTableResult> {
if (!context?.userId) {
logger.error('Unauthorized attempt to access user table - no authenticated user context')
throw new Error('Authentication required')
}
const { operation, args = {} } = params
const workspaceId =
context.workspaceId ||
((args as Record<string, unknown>).workspaceId as string | undefined)
try {
switch (operation) {
case 'create': {
if (!args.name) {
return { success: false, message: 'Name is required for creating a table' }
}
if (!args.schema) {
return { success: false, message: 'Schema is required for creating a table' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const requestId = crypto.randomUUID().slice(0, 8)
const table = await createTable(
{
name: args.name,
description: args.description,
schema: args.schema,
workspaceId,
userId: context.userId,
},
requestId
)
return {
success: true,
message: `Created table "${table.name}" (${table.id})`,
data: { table },
}
}
case 'list': {
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const tables = await listTables(workspaceId)
return {
success: true,
message: `Found ${tables.length} table(s)`,
data: { tables, totalCount: tables.length },
}
}
case 'get': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
const table = await getTableById(args.tableId)
if (!table) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
return {
success: true,
message: `Table "${table.name}" has ${table.rowCount} rows`,
data: { table },
}
}
case 'get_schema': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
const table = await getTableById(args.tableId)
if (!table) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
return {
success: true,
message: `Schema for "${table.name}"`,
data: { name: table.name, columns: table.schema.columns },
}
}
case 'delete': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
const requestId = crypto.randomUUID().slice(0, 8)
await deleteTable(args.tableId, requestId)
return {
success: true,
message: `Deleted table ${args.tableId}`,
}
}
case 'insert_row': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!args.data) {
return { success: false, message: 'Data is required for inserting a row' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const table = await getTableById(args.tableId)
if (!table) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = crypto.randomUUID().slice(0, 8)
const row = await insertRow(
{ tableId: args.tableId, data: args.data, workspaceId },
table,
requestId
)
return {
success: true,
message: `Inserted row ${row.id}`,
data: { row },
}
}
case 'batch_insert_rows': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!args.rows || args.rows.length === 0) {
return { success: false, message: 'Rows array is required and must not be empty' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const table = await getTableById(args.tableId)
if (!table) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = crypto.randomUUID().slice(0, 8)
const rows = await batchInsertRows(
{ tableId: args.tableId, rows: args.rows, workspaceId },
table,
requestId
)
return {
success: true,
message: `Inserted ${rows.length} rows`,
data: { rows, insertedCount: rows.length },
}
}
case 'get_row': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!args.rowId) {
return { success: false, message: 'Row ID is required' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const row = await getRowById(args.tableId, args.rowId, workspaceId)
if (!row) {
return { success: false, message: `Row not found: ${args.rowId}` }
}
return {
success: true,
message: `Row ${row.id}`,
data: { row },
}
}
case 'query_rows': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const requestId = crypto.randomUUID().slice(0, 8)
const result = await queryRows(
args.tableId,
workspaceId,
{
filter: args.filter,
sort: args.sort,
limit: args.limit,
offset: args.offset,
},
requestId
)
return {
success: true,
message: `Returned ${result.rows.length} of ${result.totalCount} rows`,
data: result,
}
}
case 'update_row': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!args.rowId) {
return { success: false, message: 'Row ID is required' }
}
if (!args.data) {
return { success: false, message: 'Data is required for updating a row' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const table = await getTableById(args.tableId)
if (!table) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = crypto.randomUUID().slice(0, 8)
const updatedRow = await updateRow(
{ tableId: args.tableId, rowId: args.rowId, data: args.data, workspaceId },
table,
requestId
)
return {
success: true,
message: `Updated row ${updatedRow.id}`,
data: { row: updatedRow },
}
}
case 'delete_row': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!args.rowId) {
return { success: false, message: 'Row ID is required' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const requestId = crypto.randomUUID().slice(0, 8)
await deleteRow(args.tableId, args.rowId, workspaceId, requestId)
return {
success: true,
message: `Deleted row ${args.rowId}`,
}
}
case 'update_rows_by_filter': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!args.filter) {
return { success: false, message: 'Filter is required for bulk update' }
}
if (!args.data) {
return { success: false, message: 'Data is required for bulk update' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const table = await getTableById(args.tableId)
if (!table) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = crypto.randomUUID().slice(0, 8)
const result = await updateRowsByFilter(
{
tableId: args.tableId,
filter: args.filter,
data: args.data,
limit: args.limit,
workspaceId,
},
table,
requestId
)
return {
success: true,
message: `Updated ${result.affectedCount} rows`,
data: { affectedCount: result.affectedCount, affectedRowIds: result.affectedRowIds },
}
}
case 'delete_rows_by_filter': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!args.filter) {
return { success: false, message: 'Filter is required for bulk delete' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const requestId = crypto.randomUUID().slice(0, 8)
const result = await deleteRowsByFilter(
{
tableId: args.tableId,
filter: args.filter,
limit: args.limit,
workspaceId,
},
requestId
)
return {
success: true,
message: `Deleted ${result.affectedCount} rows`,
data: { affectedCount: result.affectedCount, affectedRowIds: result.affectedRowIds },
}
}
default:
return { success: false, message: `Unknown operation: ${operation}` }
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
logger.error('Table operation failed', { operation, error: errorMessage })
return { success: false, message: `Operation failed: ${errorMessage}` }
}
},
}

View File

@@ -74,6 +74,48 @@ export const KnowledgeBaseResultSchema = z.object({
})
export type KnowledgeBaseResult = z.infer<typeof KnowledgeBaseResultSchema>
// user_table - shared schema used by server tool and registry
export const UserTableArgsSchema = z.object({
operation: z.enum([
'create',
'list',
'get',
'get_schema',
'delete',
'insert_row',
'batch_insert_rows',
'get_row',
'query_rows',
'update_row',
'delete_row',
'update_rows_by_filter',
'delete_rows_by_filter',
]),
args: z
.object({
name: z.string().optional(),
description: z.string().optional(),
schema: z.any().optional(),
tableId: z.string().optional(),
rowId: z.string().optional(),
data: z.record(z.any()).optional(),
rows: z.array(z.record(z.any())).optional(),
filter: z.any().optional(),
sort: z.record(z.enum(['asc', 'desc'])).optional(),
limit: z.number().optional(),
offset: z.number().optional(),
})
.optional(),
})
export type UserTableArgs = z.infer<typeof UserTableArgsSchema>
export const UserTableResultSchema = z.object({
success: z.boolean(),
message: z.string(),
data: z.any().optional(),
})
export type UserTableResult = z.infer<typeof UserTableResultSchema>
export const GetBlockOutputsInput = z.object({
blockIds: z.array(z.string()).optional(),
})

View File

@@ -126,6 +126,35 @@ export function serializeDocuments(
)
}
/**
* Serialize table metadata for VFS tables/{name}/meta.json
*/
export function serializeTableMeta(table: {
id: string
name: string
description?: string | null
schema: unknown
rowCount: number
maxRows: number
createdAt: Date
updatedAt: Date
}): string {
return JSON.stringify(
{
id: table.id,
name: table.name,
description: table.description || undefined,
schema: table.schema,
rowCount: table.rowCount,
maxRows: table.maxRows,
createdAt: table.createdAt.toISOString(),
updatedAt: table.updatedAt.toISOString(),
},
null,
2
)
}
/**
* Returns the static model list from PROVIDER_DEFINITIONS for VFS serialization.
* Excludes dynamic providers (ollama, vllm, openrouter) whose models are user-configured.

View File

@@ -15,6 +15,7 @@ import {
workflowMcpTool,
workspaceEnvironment,
workflowExecutionLogs,
userTableDefinitions,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, desc, eq, isNull } from 'drizzle-orm'
@@ -37,6 +38,7 @@ import {
serializeIntegrationSchema,
serializeKBMeta,
serializeRecentExecutions,
serializeTableMeta,
serializeWorkflowMeta,
} from '@/lib/copilot/vfs/serializers'
import type { DeploymentData } from '@/lib/copilot/vfs/serializers'
@@ -151,6 +153,7 @@ function getStaticComponentFiles(): Map<string, string> {
* workflows/{name}/deployment.json
* knowledgebases/{name}/meta.json
* knowledgebases/{name}/documents.json
* tables/{name}/meta.json
* custom-tools/{name}.json
* environment/credentials.json
* environment/api-keys.json
@@ -172,6 +175,7 @@ export class WorkspaceVFS {
await Promise.all([
this.materializeWorkflows(workspaceId, userId),
this.materializeKnowledgeBases(workspaceId),
this.materializeTables(workspaceId),
this.materializeEnvironment(workspaceId, userId),
this.materializeCustomTools(workspaceId),
])
@@ -363,6 +367,49 @@ export class WorkspaceVFS {
)
}
/**
* Materialize all user tables in the workspace (metadata only, no row data).
*/
private async materializeTables(workspaceId: string): Promise<void> {
try {
const tableRows = await db
.select({
id: userTableDefinitions.id,
name: userTableDefinitions.name,
description: userTableDefinitions.description,
schema: userTableDefinitions.schema,
rowCount: userTableDefinitions.rowCount,
maxRows: userTableDefinitions.maxRows,
createdAt: userTableDefinitions.createdAt,
updatedAt: userTableDefinitions.updatedAt,
})
.from(userTableDefinitions)
.where(eq(userTableDefinitions.workspaceId, workspaceId))
for (const table of tableRows) {
const safeName = sanitizeName(table.name)
this.files.set(
`tables/${safeName}/meta.json`,
serializeTableMeta({
id: table.id,
name: table.name,
description: table.description,
schema: table.schema,
rowCount: table.rowCount,
maxRows: table.maxRows,
createdAt: table.createdAt,
updatedAt: table.updatedAt,
})
)
}
} catch (err) {
logger.warn('Failed to materialize tables', {
workspaceId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Query all deployment configurations for a single workflow.
* Returns null if the workflow has no deployments of any kind.