improvement(custom-tools): make them workspace scoped + ux to manage them (#1772)

* improvement(custom-tools): make them workspace scoped

* fix auth check

* remove comments

* add dup check

* fix dup error message display

* fix tests

* fix on app loading of custom tools
This commit is contained in:
Vikhyath Mondreti
2025-10-30 17:40:38 -07:00
committed by GitHub
parent 3b901b33d1
commit a072e6d1d8
18 changed files with 8272 additions and 541 deletions

View File

@@ -12,6 +12,7 @@ describe('Custom Tools API Routes', () => {
const sampleTools = [
{
id: 'tool-1',
workspaceId: 'workspace-123',
userId: 'user-123',
title: 'Weather Tool',
schema: {
@@ -37,6 +38,7 @@ describe('Custom Tools API Routes', () => {
},
{
id: 'tool-2',
workspaceId: 'workspace-123',
userId: 'user-123',
title: 'Calculator Tool',
schema: {
@@ -82,7 +84,20 @@ describe('Custom Tools API Routes', () => {
// Reset all mock implementations
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
mockWhere.mockReturnValue({ limit: mockLimit })
// where() can be called with limit() or directly awaited
// Create a mock query builder that supports both patterns
mockWhere.mockImplementation((condition) => {
// Return an object that is both awaitable and has a limit() method
const queryBuilder = {
limit: mockLimit,
then: (resolve: (value: typeof sampleTools) => void) => {
resolve(sampleTools)
return queryBuilder
},
catch: (reject: (error: Error) => void) => queryBuilder,
}
return queryBuilder
})
mockLimit.mockResolvedValue(sampleTools)
mockInsert.mockReturnValue({ values: mockValues })
mockValues.mockResolvedValue({ id: 'new-tool-id' })
@@ -99,11 +114,34 @@ describe('Custom Tools API Routes', () => {
delete: mockDelete,
transaction: vi.fn().mockImplementation(async (callback) => {
// Execute the callback with a transaction object that has the same methods
// Create transaction-specific mocks that follow the same pattern
const txMockSelect = vi.fn().mockReturnValue({ from: mockFrom })
const txMockInsert = vi.fn().mockReturnValue({ values: mockValues })
const txMockUpdate = vi.fn().mockReturnValue({ set: mockSet })
const txMockDelete = vi.fn().mockReturnValue({ where: mockWhere })
// Transaction where() should also support the query builder pattern
const txMockWhere = vi.fn().mockImplementation((condition) => {
const queryBuilder = {
limit: mockLimit,
then: (resolve: (value: typeof sampleTools) => void) => {
resolve(sampleTools)
return queryBuilder
},
catch: (reject: (error: Error) => void) => queryBuilder,
}
return queryBuilder
})
// Update mockFrom to return txMockWhere for transaction queries
const txMockFrom = vi.fn().mockReturnValue({ where: txMockWhere })
txMockSelect.mockReturnValue({ from: txMockFrom })
return await callback({
select: mockSelect,
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
select: txMockSelect,
insert: txMockInsert,
update: txMockUpdate,
delete: txMockDelete,
})
}),
},
@@ -112,8 +150,15 @@ describe('Custom Tools API Routes', () => {
// Mock schema
vi.doMock('@sim/db/schema', () => ({
customTools: {
userId: 'userId', // Add these properties to enable WHERE clauses with eq()
id: 'id',
workspaceId: 'workspaceId',
userId: 'userId',
title: 'title',
},
workflow: {
id: 'id',
workspaceId: 'workspaceId',
userId: 'userId',
},
}))
@@ -122,9 +167,18 @@ describe('Custom Tools API Routes', () => {
getSession: vi.fn().mockResolvedValue(mockSession),
}))
// Mock getUserId
vi.doMock('@/app/api/auth/oauth/utils', () => ({
getUserId: vi.fn().mockResolvedValue('user-123'),
// Mock hybrid auth
vi.doMock('@/lib/auth/hybrid', () => ({
checkHybridAuth: vi.fn().mockResolvedValue({
success: true,
userId: 'user-123',
authType: 'session',
}),
}))
// Mock permissions
vi.doMock('@/lib/permissions/utils', () => ({
getUserEntityPermissions: vi.fn().mockResolvedValue('admin'),
}))
// Mock logger
@@ -137,14 +191,23 @@ describe('Custom Tools API Routes', () => {
}),
}))
// Mock eq function from drizzle-orm
// Mock drizzle-orm functions
vi.doMock('drizzle-orm', async () => {
const actual = await vi.importActual('drizzle-orm')
return {
...(actual as object),
eq: vi.fn().mockImplementation((field, value) => ({ field, value, operator: 'eq' })),
and: vi.fn().mockImplementation((...conditions) => ({ operator: 'and', conditions })),
or: vi.fn().mockImplementation((...conditions) => ({ operator: 'or', conditions })),
isNull: vi.fn().mockImplementation((field) => ({ field, operator: 'isNull' })),
ne: vi.fn().mockImplementation((field, value) => ({ field, value, operator: 'ne' })),
}
})
// Mock utils
vi.doMock('@/lib/utils', () => ({
generateRequestId: vi.fn().mockReturnValue('test-request-id'),
}))
})
afterEach(() => {
@@ -155,9 +218,11 @@ describe('Custom Tools API Routes', () => {
* Test GET endpoint
*/
describe('GET /api/tools/custom', () => {
it('should return tools for authenticated user', async () => {
// Create mock request
const req = createMockRequest('GET')
it('should return tools for authenticated user with workspaceId', async () => {
// Create mock request with workspaceId
const req = new NextRequest(
'http://localhost:3000/api/tools/custom?workspaceId=workspace-123'
)
// Simulate DB returning tools
mockWhere.mockReturnValueOnce(Promise.resolve(sampleTools))
@@ -182,11 +247,16 @@ describe('Custom Tools API Routes', () => {
it('should handle unauthorized access', async () => {
// Create mock request
const req = createMockRequest('GET')
const req = new NextRequest(
'http://localhost:3000/api/tools/custom?workspaceId=workspace-123'
)
// Mock session to return no user
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue(null),
// Mock hybrid auth to return unauthorized
vi.doMock('@/lib/auth/hybrid', () => ({
checkHybridAuth: vi.fn().mockResolvedValue({
success: false,
error: 'Unauthorized',
}),
}))
// Import handler after mocks are set up
@@ -205,17 +275,34 @@ describe('Custom Tools API Routes', () => {
// Create mock request with workflowId parameter
const req = new NextRequest('http://localhost:3000/api/tools/custom?workflowId=workflow-123')
// Mock workflow lookup to return workspaceId (for limit(1) call)
mockLimit.mockResolvedValueOnce([{ workspaceId: 'workspace-123' }])
// Mock the where() call for fetching tools (returns awaitable query builder)
mockWhere.mockImplementationOnce((condition) => {
const queryBuilder = {
limit: mockLimit,
then: (resolve: (value: typeof sampleTools) => void) => {
resolve(sampleTools)
return queryBuilder
},
catch: (reject: (error: Error) => void) => queryBuilder,
}
return queryBuilder
})
// Import handler after mocks are set up
const { GET } = await import('@/app/api/tools/custom/route')
// Call the handler
const _response = await GET(req)
const response = await GET(req)
const data = await response.json()
// Verify getUserId was called with correct parameters
const getUserId = (await import('@/app/api/auth/oauth/utils')).getUserId
expect(getUserId).toHaveBeenCalledWith(expect.any(String), 'workflow-123')
// Verify response
expect(response.status).toBe(200)
expect(data).toHaveProperty('data')
// Verify DB query filters by user
// Verify DB query was called
expect(mockWhere).toHaveBeenCalled()
})
})
@@ -224,95 +311,17 @@ describe('Custom Tools API Routes', () => {
* Test POST endpoint
*/
describe('POST /api/tools/custom', () => {
it('should create new tools when IDs are not provided', async () => {
// Create test tool data
const newTool = {
title: 'New Tool',
schema: {
type: 'function',
function: {
name: 'newTool',
description: 'A brand new tool',
parameters: {
type: 'object',
properties: {},
required: [],
},
},
},
code: 'return "hello world";',
}
// Create mock request with new tool
const req = createMockRequest('POST', { tools: [newTool] })
// Import handler after mocks are set up
const { POST } = await import('@/app/api/tools/custom/route')
// Call the handler
const response = await POST(req)
const data = await response.json()
// Verify response
expect(response.status).toBe(200)
expect(data).toHaveProperty('success', true)
// Verify insert was called with correct parameters
expect(mockInsert).toHaveBeenCalled()
expect(mockValues).toHaveBeenCalled()
})
it('should update existing tools when ID is provided', async () => {
// Create test tool data with ID
const updateTool = {
id: 'tool-1',
title: 'Updated Weather Tool',
schema: {
type: 'function',
function: {
name: 'getWeatherUpdate',
description: 'Get updated weather information',
parameters: {
type: 'object',
properties: {},
required: [],
},
},
},
code: 'return { temperature: 75, conditions: "partly cloudy" };',
}
// Mock DB to find existing tool
mockLimit.mockResolvedValueOnce([sampleTools[0]])
// Create mock request with tool update
const req = createMockRequest('POST', { tools: [updateTool] })
// Import handler after mocks are set up
const { POST } = await import('@/app/api/tools/custom/route')
// Call the handler
const response = await POST(req)
const data = await response.json()
// Verify response
expect(response.status).toBe(200)
expect(data).toHaveProperty('success', true)
// Verify update was called with correct parameters
expect(mockUpdate).toHaveBeenCalled()
expect(mockSet).toHaveBeenCalled()
expect(mockWhere).toHaveBeenCalled()
})
it('should reject unauthorized requests', async () => {
// Mock session to return no user
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue(null),
// Mock hybrid auth to return unauthorized
vi.doMock('@/lib/auth/hybrid', () => ({
checkHybridAuth: vi.fn().mockResolvedValue({
success: false,
error: 'Unauthorized',
}),
}))
// Create mock request
const req = createMockRequest('POST', { tools: [] })
const req = createMockRequest('POST', { tools: [], workspaceId: 'workspace-123' })
// Import handler after mocks are set up
const { POST } = await import('@/app/api/tools/custom/route')
@@ -333,8 +342,8 @@ describe('Custom Tools API Routes', () => {
code: 'return "invalid";',
}
// Create mock request with invalid tool
const req = createMockRequest('POST', { tools: [invalidTool] })
// Create mock request with invalid tool and workspaceId
const req = createMockRequest('POST', { tools: [invalidTool], workspaceId: 'workspace-123' })
// Import handler after mocks are set up
const { POST } = await import('@/app/api/tools/custom/route')
@@ -354,12 +363,14 @@ describe('Custom Tools API Routes', () => {
* Test DELETE endpoint
*/
describe('DELETE /api/tools/custom', () => {
it('should delete a tool by ID', async () => {
// Mock finding existing tool
it('should delete a workspace-scoped tool by ID', async () => {
// Mock finding existing workspace-scoped tool
mockLimit.mockResolvedValueOnce([sampleTools[0]])
// Create mock request with ID parameter
const req = new NextRequest('http://localhost:3000/api/tools/custom?id=tool-1')
// Create mock request with ID and workspaceId parameters
const req = new NextRequest(
'http://localhost:3000/api/tools/custom?id=tool-1&workspaceId=workspace-123'
)
// Import handler after mocks are set up
const { DELETE } = await import('@/app/api/tools/custom/route')
@@ -412,12 +423,21 @@ describe('Custom Tools API Routes', () => {
expect(data).toHaveProperty('error', 'Tool not found')
})
it('should prevent unauthorized deletion', async () => {
// Mock finding tool that belongs to a different user
const otherUserTool = { ...sampleTools[0], userId: 'different-user' }
mockLimit.mockResolvedValueOnce([otherUserTool])
it('should prevent unauthorized deletion of user-scoped tool', async () => {
// Mock hybrid auth for the DELETE request
vi.doMock('@/lib/auth/hybrid', () => ({
checkHybridAuth: vi.fn().mockResolvedValue({
success: true,
userId: 'user-456', // Different user
authType: 'session',
}),
}))
// Create mock request
// Mock finding user-scoped tool (no workspaceId) that belongs to user-123
const userScopedTool = { ...sampleTools[0], workspaceId: null, userId: 'user-123' }
mockLimit.mockResolvedValueOnce([userScopedTool])
// Create mock request (no workspaceId for user-scoped tool)
const req = new NextRequest('http://localhost:3000/api/tools/custom?id=tool-1')
// Import handler after mocks are set up
@@ -429,13 +449,16 @@ describe('Custom Tools API Routes', () => {
// Verify response
expect(response.status).toBe(403)
expect(data).toHaveProperty('error', 'Unauthorized')
expect(data).toHaveProperty('error', 'Access denied')
})
it('should reject unauthorized requests', async () => {
// Mock session to return no user
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue(null),
// Mock hybrid auth to return unauthorized
vi.doMock('@/lib/auth/hybrid', () => ({
checkHybridAuth: vi.fn().mockResolvedValue({
success: false,
error: 'Unauthorized',
}),
}))
// Create mock request

View File

@@ -1,12 +1,12 @@
import { db } from '@sim/db'
import { customTools } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { customTools, workflow } from '@sim/db/schema'
import { and, eq, isNull, ne, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { generateRequestId } from '@/lib/utils'
import { getUserId } from '@/app/api/auth/oauth/utils'
const logger = createLogger('CustomToolsAPI')
@@ -30,36 +30,77 @@ const CustomToolSchema = z.object({
code: z.string(),
})
),
workspaceId: z.string().optional(),
})
// GET - Fetch all custom tools for the user
// GET - Fetch all custom tools for the workspace
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
const searchParams = request.nextUrl.searchParams
const workspaceId = searchParams.get('workspaceId')
const workflowId = searchParams.get('workflowId')
try {
let userId: string | undefined
// If workflowId is provided, get userId from the workflow
if (workflowId) {
userId = await getUserId(requestId, workflowId)
if (!userId) {
logger.warn(`[${requestId}] No valid user found for workflow: ${workflowId}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
} else {
// Otherwise use session-based auth (for client-side)
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized custom tools access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
userId = session.user.id
// Use hybrid auth to support session, API key, and internal JWT
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized custom tools access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const result = await db.select().from(customTools).where(eq(customTools.userId, userId))
const userId = authResult.userId
let resolvedWorkspaceId: string | null = workspaceId
if (!resolvedWorkspaceId && workflowId) {
const [workflowData] = await db
.select({ workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowData) {
logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
resolvedWorkspaceId = workflowData.workspaceId
}
// Check workspace permissions
// For internal JWT with workflowId: checkHybridAuth already resolved userId from workflow owner
// For session/API key: verify user has access to the workspace
// For legacy (no workspaceId): skip workspace check, rely on userId match
if (resolvedWorkspaceId && !(authResult.authType === 'internal_jwt' && workflowId)) {
const userPermission = await getUserEntityPermissions(
userId,
'workspace',
resolvedWorkspaceId
)
if (!userPermission) {
logger.warn(
`[${requestId}] User ${userId} does not have access to workspace ${resolvedWorkspaceId}`
)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
}
// Build query to fetch tools
// 1. Workspace-scoped tools: tools with matching workspaceId
// 2. User-scoped legacy tools: tools with null workspaceId and matching userId
const conditions = []
if (resolvedWorkspaceId) {
conditions.push(eq(customTools.workspaceId, resolvedWorkspaceId))
}
// Always include legacy user-scoped tools for backward compatibility
conditions.push(and(isNull(customTools.workspaceId), eq(customTools.userId, userId)))
const result = await db
.select()
.from(customTools)
.where(or(...conditions))
return NextResponse.json({ data: result }, { status: 200 })
} catch (error) {
@@ -73,17 +114,41 @@ export async function POST(req: NextRequest) {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
// Use hybrid auth (though this endpoint is only called from UI)
const authResult = await checkHybridAuth(req, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized custom tools update attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = authResult.userId
const body = await req.json()
try {
// Validate the request body
const { tools } = CustomToolSchema.parse(body)
const { tools, workspaceId } = CustomToolSchema.parse(body)
if (!workspaceId) {
logger.warn(`[${requestId}] Missing workspaceId in request body`)
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
}
// Check workspace permissions
const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!userPermission) {
logger.warn(
`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`
)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Check write permission
if (userPermission !== 'admin' && userPermission !== 'write') {
logger.warn(
`[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}`
)
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
}
// Use a transaction for multi-step database operations
return await db.transaction(async (tx) => {
@@ -92,26 +157,40 @@ export async function POST(req: NextRequest) {
const nowTime = new Date()
if (tool.id) {
// First check if this tool belongs to the user
// Check if tool exists and belongs to the workspace
const existingTool = await tx
.select()
.from(customTools)
.where(eq(customTools.id, tool.id))
.where(and(eq(customTools.id, tool.id), eq(customTools.workspaceId, workspaceId)))
.limit(1)
if (existingTool.length === 0) {
// Tool doesn't exist, create it
await tx.insert(customTools).values({
id: tool.id,
userId: session.user.id,
title: tool.title,
schema: tool.schema,
code: tool.code,
createdAt: nowTime,
updatedAt: nowTime,
})
} else if (existingTool[0].userId === session.user.id) {
// Tool exists and belongs to user, update it
if (existingTool.length > 0) {
// Tool exists - check if name changed and if new name conflicts
if (existingTool[0].title !== tool.title) {
// Check for duplicate name in workspace (excluding current tool)
const duplicateTool = await tx
.select()
.from(customTools)
.where(
and(
eq(customTools.workspaceId, workspaceId),
eq(customTools.title, tool.title),
ne(customTools.id, tool.id)
)
)
.limit(1)
if (duplicateTool.length > 0) {
return NextResponse.json(
{
error: `A tool with the name "${tool.title}" already exists in this workspace`,
},
{ status: 400 }
)
}
}
// Update existing tool
await tx
.update(customTools)
.set({
@@ -120,28 +199,46 @@ export async function POST(req: NextRequest) {
code: tool.code,
updatedAt: nowTime,
})
.where(eq(customTools.id, tool.id))
} else {
// Log and silently continue if user attempts to update a tool they don't own
logger.warn(
`[${requestId}] Silent continuation on unauthorized tool update attempt: ${tool.id}`
)
.where(and(eq(customTools.id, tool.id), eq(customTools.workspaceId, workspaceId)))
continue
}
} else {
// No ID provided, create a new tool
await tx.insert(customTools).values({
id: crypto.randomUUID(),
userId: session.user.id,
title: tool.title,
schema: tool.schema,
code: tool.code,
createdAt: nowTime,
updatedAt: nowTime,
})
}
// Creating new tool - check for duplicate names in workspace
const duplicateTool = await tx
.select()
.from(customTools)
.where(and(eq(customTools.workspaceId, workspaceId), eq(customTools.title, tool.title)))
.limit(1)
if (duplicateTool.length > 0) {
return NextResponse.json(
{ error: `A tool with the name "${tool.title}" already exists in this workspace` },
{ status: 400 }
)
}
// Create new tool
const newToolId = tool.id || crypto.randomUUID()
await tx.insert(customTools).values({
id: newToolId,
workspaceId,
userId,
title: tool.title,
schema: tool.schema,
code: tool.code,
createdAt: nowTime,
updatedAt: nowTime,
})
}
return NextResponse.json({ success: true })
// Fetch and return the created/updated tools
const resultTools = await tx
.select()
.from(customTools)
.where(eq(customTools.workspaceId, workspaceId))
return NextResponse.json({ success: true, data: resultTools })
})
} catch (validationError) {
if (validationError instanceof z.ZodError) {
@@ -166,6 +263,7 @@ export async function DELETE(request: NextRequest) {
const requestId = generateRequestId()
const searchParams = request.nextUrl.searchParams
const toolId = searchParams.get('id')
const workspaceId = searchParams.get('workspaceId')
if (!toolId) {
logger.warn(`[${requestId}] Missing tool ID for deletion`)
@@ -173,13 +271,16 @@ export async function DELETE(request: NextRequest) {
}
try {
const session = await getSession()
if (!session?.user?.id) {
// Use hybrid auth (though this endpoint is only called from UI)
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized custom tool deletion attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if the tool exists and belongs to the user
const userId = authResult.userId
// Check if the tool exists
const existingTool = await db
.select()
.from(customTools)
@@ -191,9 +292,46 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'Tool not found' }, { status: 404 })
}
if (existingTool[0].userId !== session.user.id) {
logger.warn(`[${requestId}] User attempted to delete a tool they don't own: ${toolId}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
const tool = existingTool[0]
// Handle workspace-scoped tools
if (tool.workspaceId) {
if (!workspaceId) {
logger.warn(`[${requestId}] Missing workspaceId for workspace-scoped tool`)
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
}
// Check workspace permissions
const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!userPermission) {
logger.warn(
`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`
)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Check write permission
if (userPermission !== 'admin' && userPermission !== 'write') {
logger.warn(
`[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}`
)
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
}
// Verify tool belongs to this workspace
if (tool.workspaceId !== workspaceId) {
logger.warn(`[${requestId}] Tool ${toolId} does not belong to workspace ${workspaceId}`)
return NextResponse.json({ error: 'Tool not found' }, { status: 404 })
}
} else {
// Handle legacy user-scoped tools (no workspaceId)
// Only allow deletion if user owns the tool
if (tool.userId !== userId) {
logger.warn(
`[${requestId}] User ${userId} attempted to delete tool they don't own: ${toolId}`
)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
}
// Delete the tool

View File

@@ -197,14 +197,30 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
// Extract and persist custom tools to database
try {
const { saved, errors } = await extractAndPersistCustomTools(workflowState, userId)
const workspaceId = workflowData.workspaceId
if (workspaceId) {
const { saved, errors } = await extractAndPersistCustomTools(
workflowState,
workspaceId,
userId
)
if (saved > 0) {
logger.info(`[${requestId}] Persisted ${saved} custom tool(s) to database`, { workflowId })
}
if (saved > 0) {
logger.info(`[${requestId}] Persisted ${saved} custom tool(s) to database`, {
workflowId,
})
}
if (errors.length > 0) {
logger.warn(`[${requestId}] Some custom tools failed to persist`, { errors, workflowId })
if (errors.length > 0) {
logger.warn(`[${requestId}] Some custom tools failed to persist`, { errors, workflowId })
}
} else {
logger.warn(
`[${requestId}] Workflow has no workspaceId, skipping custom tools persistence`,
{
workflowId,
}
)
}
} catch (error) {
logger.error(`[${requestId}] Failed to persist custom tools`, { error, workflowId })

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { AlertTriangle, Code, FileJson, Trash2, X } from 'lucide-react'
import { AlertCircle, Code, FileJson, Trash2, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
AlertDialog,
AlertDialogAction,
@@ -23,7 +24,6 @@ import {
import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-dropdown'
import { Label } from '@/components/ui/label'
import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
@@ -156,8 +156,8 @@ Example 2:
},
currentValue: jsonSchema,
onGeneratedContent: (content) => {
handleJsonSchemaChange(content)
setSchemaError(null) // Clear error on successful generation
setJsonSchema(content)
setSchemaError(null)
},
onStreamChunk: (chunk) => {
setJsonSchema((prev) => {
@@ -254,9 +254,9 @@ try {
// Schema params keyboard navigation
const [schemaParamSelectedIndex, setSchemaParamSelectedIndex] = useState(0)
const addTool = useCustomToolsStore((state) => state.addTool)
const createTool = useCustomToolsStore((state) => state.createTool)
const updateTool = useCustomToolsStore((state) => state.updateTool)
const removeTool = useCustomToolsStore((state) => state.removeTool)
const deleteTool = useCustomToolsStore((state) => state.deleteTool)
// Initialize form with initial values if provided
useEffect(() => {
@@ -303,7 +303,6 @@ try {
setActiveSection('schema')
setIsEditing(false)
setToolId(undefined)
// Reset AI state as well
schemaGeneration.closePrompt()
schemaGeneration.hidePromptInline()
codeGeneration.closePrompt()
@@ -377,18 +376,15 @@ try {
const isSchemaValid = useMemo(() => validateJsonSchema(jsonSchema), [jsonSchema])
const isCodeValid = useMemo(() => validateFunctionCode(functionCode), [functionCode])
const handleSave = () => {
setSchemaError(null)
setCodeError(null)
// Validation with error messages
if (!jsonSchema) {
setSchemaError('Schema cannot be empty')
setActiveSection('schema')
return
}
const handleSave = async () => {
try {
// Validation with error messages
if (!jsonSchema) {
setSchemaError('Schema cannot be empty')
setActiveSection('schema')
return
}
const parsed = JSON.parse(jsonSchema)
if (!parsed.type || parsed.type !== 'function') {
@@ -431,66 +427,49 @@ try {
return
}
// Check for duplicate tool name
const toolName = parsed.function.name
const customToolsStore = useCustomToolsStore.getState()
const existingTools = customToolsStore.getAllTools()
// No errors, proceed with save - clear any existing errors
setSchemaError(null)
setCodeError(null)
// If editing, we need to find the original tool to get its ID
let originalToolId = toolId
if (isEditing && !originalToolId) {
// If we're editing but don't have an ID, try to find the tool by its original name
const originalSchema = initialValues?.schema
const originalName = originalSchema?.function?.name
if (originalName) {
const originalTool = existingTools.find(
(tool) => tool.schema.function.name === originalName
)
if (originalTool) {
originalToolId = originalTool.id
}
}
}
// Check for duplicates, excluding the current tool if editing
const isDuplicate = existingTools.some((tool) => {
// Skip the current tool when checking for duplicates
if (isEditing && tool.id === originalToolId) {
return false
}
return tool.schema.function.name === toolName
})
if (isDuplicate) {
setSchemaError(`A tool with the name "${toolName}" already exists`)
setActiveSection('schema')
return
}
// Save to custom tools store
// Parse schema to get tool details
const schema = JSON.parse(jsonSchema)
const name = schema.function.name
const description = schema.function.description || ''
let _finalToolId: string | undefined = originalToolId
// Determine the tool ID for editing
let toolIdToUpdate: string | undefined = toolId
if (isEditing && !toolIdToUpdate && initialValues?.schema) {
const originalName = initialValues.schema.function?.name
if (originalName) {
const customToolsStore = useCustomToolsStore.getState()
const existingTools = customToolsStore.getAllTools()
const originalTool = existingTools.find(
(tool) => tool.schema.function.name === originalName
)
if (originalTool) {
toolIdToUpdate = originalTool.id
}
}
}
// Only save to the store if we're not reusing an existing tool
if (isEditing && originalToolId) {
// Update existing tool in store
updateTool(originalToolId, {
// Save to the store (server validates duplicates)
let _finalToolId: string | undefined = toolIdToUpdate
if (isEditing && toolIdToUpdate) {
// Update existing tool
await updateTool(workspaceId, toolIdToUpdate, {
title: name,
schema,
code: functionCode || '',
})
} else {
// Add new tool to store
_finalToolId = addTool({
// Create new tool
const createdTool = await createTool(workspaceId, {
title: name,
schema,
code: functionCode || '',
})
_finalToolId = createdTool.id
}
// Create the custom tool object for the parent component
@@ -512,7 +491,19 @@ try {
handleClose()
} catch (error) {
logger.error('Error saving custom tool:', { error })
setSchemaError('Failed to save custom tool. Please check your inputs and try again.')
// Check if it's an API error with status code (from store)
const hasStatus = error && typeof error === 'object' && 'status' in error
const errorStatus = hasStatus ? (error as { status: number }).status : null
const errorMessage = error instanceof Error ? error.message : 'Failed to save custom tool'
// Display server validation errors (400) directly, generic message for others
setSchemaError(
errorStatus === 400
? errorMessage
: 'Failed to save custom tool. Please check your inputs and try again.'
)
setActiveSection('schema')
}
}
@@ -784,19 +775,8 @@ try {
try {
setShowDeleteConfirm(false)
// Call API to delete the tool
const response = await fetch(`/api/tools/custom?id=${toolId}`, {
method: 'DELETE',
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const errorMessage = errorData.error || response.statusText || 'Failed to delete tool'
throw new Error(errorMessage)
}
// Remove from local store
removeTool(toolId)
// Delete from store (which calls the API)
await deleteTool(workspaceId, toolId)
logger.info(`Deleted tool: ${toolId}`)
// Notify parent component if callback provided
@@ -863,6 +843,16 @@ try {
</DialogDescription>
</DialogHeader>
{/* Error Alert */}
{schemaError && (
<div className='px-6 pt-4'>
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>{schemaError}</AlertDescription>
</Alert>
</div>
)}
<div className='flex min-h-0 flex-1 flex-col overflow-hidden'>
<div className='flex border-b'>
{navigationItems.map((item) => (
@@ -934,17 +924,6 @@ try {
<Label htmlFor='json-schema' className='font-medium'>
JSON Schema
</Label>
{schemaError &&
!schemaGeneration.isStreaming && ( // Hide schema error while streaming
<Tooltip>
<TooltipTrigger asChild>
<AlertTriangle className='h-4 w-4 cursor-pointer text-destructive' />
</TooltipTrigger>
<TooltipContent side='top'>
<p>Invalid JSON</p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
<CodeEditor
@@ -1186,20 +1165,9 @@ try {
Next
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button onClick={handleSave} disabled={!isSchemaValid}>
{isEditing ? 'Update Tool' : 'Save Tool'}
</Button>
</span>
</TooltipTrigger>
{!isSchemaValid && (
<TooltipContent side='top'>
<p>Invalid JSON schema</p>
</TooltipContent>
)}
</Tooltip>
<Button onClick={handleSave} disabled={!isSchemaValid || !!schemaError}>
{isEditing ? 'Update Tool' : 'Save Tool'}
</Button>
)}
</div>
</div>

View File

@@ -0,0 +1,217 @@
'use client'
import { useEffect, useState } from 'react'
import { AlertCircle, Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Alert, AlertDescription, Button, Input, Skeleton } from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
import { useCustomToolsStore } from '@/stores/custom-tools/store'
const logger = createLogger('CustomToolsSettings')
function CustomToolSkeleton() {
return (
<div className='rounded-[8px] border bg-background p-4'>
<div className='flex items-center justify-between'>
<div className='flex-1 space-y-2'>
<Skeleton className='h-4 w-32' />
<Skeleton className='h-3 w-48' />
</div>
<Skeleton className='h-8 w-20' />
</div>
</div>
)
}
export function CustomTools() {
const params = useParams()
const workspaceId = params.workspaceId as string
const { tools, isLoading, error, fetchTools, deleteTool, clearError } = useCustomToolsStore()
const [searchTerm, setSearchTerm] = useState('')
const [deletingTools, setDeletingTools] = useState<Set<string>>(new Set())
const [editingTool, setEditingTool] = useState<string | null>(null)
const [showAddForm, setShowAddForm] = useState(false)
useEffect(() => {
if (workspaceId) {
fetchTools(workspaceId)
}
}, [workspaceId, fetchTools])
// Clear store errors when modal opens (errors should show in modal, not in settings)
useEffect(() => {
if (showAddForm || editingTool) {
clearError()
}
}, [showAddForm, editingTool, clearError])
const filteredTools = tools.filter((tool) => {
if (!searchTerm.trim()) return true
const searchLower = searchTerm.toLowerCase()
return (
tool.title.toLowerCase().includes(searchLower) ||
tool.schema?.function?.name?.toLowerCase().includes(searchLower) ||
tool.schema?.function?.description?.toLowerCase().includes(searchLower)
)
})
const handleDeleteTool = async (toolId: string) => {
const tool = tools.find((t) => t.id === toolId)
if (!tool) return
setDeletingTools((prev) => new Set(prev).add(toolId))
try {
// Pass null workspaceId for user-scoped tools (legacy tools without workspaceId)
await deleteTool(tool.workspaceId ?? null, toolId)
logger.info(`Deleted custom tool: ${toolId}`)
// Silently refresh the list - no toast notification
if (workspaceId) {
await fetchTools(workspaceId)
}
} catch (error) {
logger.error('Error deleting custom tool:', error)
// Silently handle error - no toast notification
} finally {
setDeletingTools((prev) => {
const next = new Set(prev)
next.delete(toolId)
return next
})
}
}
const handleToolSaved = () => {
setShowAddForm(false)
setEditingTool(null)
if (workspaceId) {
fetchTools(workspaceId)
}
}
return (
<div className='flex h-full flex-col'>
{/* Header */}
<div className='border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<div>
<h2 className='font-semibold text-foreground text-lg'>Custom Tools</h2>
<p className='text-muted-foreground text-sm mt-1'>
Manage workspace-scoped custom tools for your agents
</p>
</div>
{!showAddForm && !editingTool && (
<Button size='sm' onClick={() => setShowAddForm(true)} className='h-9'>
<Plus className='mr-2 h-4 w-4' />
Add Tool
</Button>
)}
</div>
{/* Search */}
{tools.length > 0 && !showAddForm && !editingTool && (
<div className='mt-4 flex h-9 w-56 items-center gap-2 rounded-lg border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
<Input
placeholder='Search tools...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
)}
{/* Error Alert - only show when modal is not open */}
{error && !showAddForm && !editingTool && (
<Alert variant='destructive' className='mt-4'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
{/* Scrollable Content */}
<div className='scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'>
<div className='h-full space-y-4 py-2'>
{isLoading ? (
<div className='space-y-4'>
<CustomToolSkeleton />
<CustomToolSkeleton />
<CustomToolSkeleton />
</div>
) : filteredTools.length === 0 && !showAddForm && !editingTool ? (
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
{searchTerm.trim() ? (
<>No tools found matching "{searchTerm}"</>
) : (
<>Click "Add Tool" above to create your first custom tool</>
)}
</div>
) : (
<div className='space-y-4'>
{filteredTools.map((tool) => (
<div
key={tool.id}
className='flex items-center justify-between gap-4 rounded-[8px] border bg-background p-4'
>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2 mb-1'>
<code className='font-mono text-foreground text-sm font-medium'>
{tool.title}
</code>
</div>
{tool.schema?.function?.description && (
<p className='text-muted-foreground text-xs truncate'>
{tool.schema.function.description}
</p>
)}
</div>
<div className='flex items-center gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => setEditingTool(tool.id)}
className='h-8 text-muted-foreground hover:text-foreground'
>
Edit
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => handleDeleteTool(tool.id)}
disabled={deletingTools.has(tool.id)}
className='h-8 text-muted-foreground hover:text-foreground'
>
{deletingTools.has(tool.id) ? 'Deleting...' : 'Delete'}
</Button>
</div>
</div>
))}
{searchTerm.trim() && filteredTools.length === 0 && tools.length > 0 && (
<div className='py-8 text-center text-muted-foreground text-sm'>
No tools found matching "{searchTerm}"
</div>
)}
</div>
)}
</div>
</div>
{/* Create/Edit Modal - rendered as overlay */}
<CustomToolModal
open={showAddForm || !!editingTool}
onOpenChange={(open) => {
if (!open) {
setShowAddForm(false)
setEditingTool(null)
}
}}
onSave={handleToolSaved}
onDelete={() => {}}
blockId=''
initialValues={editingTool ? tools.find((t) => t.id === editingTool) : undefined}
/>
</div>
)
}

View File

@@ -2,6 +2,7 @@ export { Account } from './account/account'
export { ApiKeys } from './api-keys/api-keys'
export { Copilot } from './copilot/copilot'
export { Credentials } from './credentials/credentials'
export { CustomTools } from './custom-tools/custom-tools'
export { EnvironmentVariables } from './environment/environment'
export { FileUploads } from './file-uploads/file-uploads'
export { General } from './general/general'

View File

@@ -13,6 +13,7 @@ import {
User,
Users,
Waypoints,
Wrench,
} from 'lucide-react'
import { useSession } from '@/lib/auth-client'
import { getEnv, isTruthy } from '@/lib/env'
@@ -40,6 +41,7 @@ interface SettingsNavigationProps {
| 'privacy'
| 'copilot'
| 'mcp'
| 'custom-tools'
) => void
hasOrganization: boolean
}
@@ -58,6 +60,7 @@ type NavigationItem = {
| 'copilot'
| 'privacy'
| 'mcp'
| 'custom-tools'
label: string
icon: React.ComponentType<{ className?: string }>
hideWhenBillingDisabled?: boolean
@@ -82,6 +85,11 @@ const allNavigationItems: NavigationItem[] = [
label: 'MCP Servers',
icon: Server,
},
{
id: 'custom-tools',
label: 'Custom Tools',
icon: Wrench,
},
{
id: 'environment',
label: 'Environment',

View File

@@ -9,6 +9,7 @@ import {
ApiKeys,
Copilot,
Credentials,
CustomTools,
EnvironmentVariables,
FileUploads,
General,
@@ -44,6 +45,7 @@ type SettingsSection =
| 'privacy'
| 'copilot'
| 'mcp'
| 'custom-tools'
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
@@ -202,6 +204,11 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
<MCP />
</div>
)}
{activeSection === 'custom-tools' && (
<div className='h-full'>
<CustomTools />
</div>
)}
</div>
</div>
</DialogContent>

View File

@@ -898,18 +898,32 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, any> = {
// Extract and persist custom tools to database
if (context?.userId) {
try {
const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState
const { saved, errors } = await extractAndPersistCustomTools(
finalWorkflowState,
context.userId
)
// Get workspaceId from the workflow
const [workflowRecord] = await db
.select({ workspaceId: workflowTable.workspaceId })
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (saved > 0) {
logger.info(`Persisted ${saved} custom tool(s) to database`, { workflowId })
}
if (workflowRecord?.workspaceId) {
const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState
const { saved, errors } = await extractAndPersistCustomTools(
finalWorkflowState,
workflowRecord.workspaceId,
context.userId
)
if (errors.length > 0) {
logger.warn('Some custom tools failed to persist', { errors, workflowId })
if (saved > 0) {
logger.info(`Persisted ${saved} custom tool(s) to database`, { workflowId })
}
if (errors.length > 0) {
logger.warn('Some custom tools failed to persist', { errors, workflowId })
}
} else {
logger.warn('Workflow has no workspaceId, skipping custom tools persistence', {
workflowId,
})
}
} catch (error) {
logger.error('Failed to persist custom tools', { error, workflowId })

View File

@@ -1,6 +1,6 @@
import { db } from '@sim/db'
import { customTools } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('CustomToolsPersistence')
@@ -96,12 +96,20 @@ export function extractCustomToolsFromWorkflowState(workflowState: any): CustomT
*/
export async function persistCustomToolsToDatabase(
customToolsList: CustomTool[],
workspaceId: string | null,
userId: string
): Promise<{ saved: number; errors: string[] }> {
if (!customToolsList || customToolsList.length === 0) {
return { saved: 0, errors: [] }
}
// Only persist if workspaceId is provided (new workspace-scoped tools)
// Skip persistence for existing user-scoped tools to maintain backward compatibility
if (!workspaceId) {
logger.debug('Skipping custom tools persistence - no workspaceId provided (user-scoped tools)')
return { saved: 0, errors: [] }
}
const errors: string[] = []
let saved = 0
@@ -123,17 +131,18 @@ export async function persistCustomToolsToDatabase(
const nowTime = new Date()
// Check if tool already exists
// Check if tool already exists in this workspace
const existingTool = await tx
.select()
.from(customTools)
.where(eq(customTools.id, baseId))
.where(and(eq(customTools.id, baseId), eq(customTools.workspaceId, workspaceId)))
.limit(1)
if (existingTool.length === 0) {
// Create new tool
await tx.insert(customTools).values({
id: baseId,
workspaceId,
userId,
title: tool.title,
schema: tool.schema,
@@ -142,10 +151,10 @@ export async function persistCustomToolsToDatabase(
updatedAt: nowTime,
})
logger.info(`Created custom tool: ${tool.title}`, { toolId: baseId })
logger.info(`Created custom tool: ${tool.title}`, { toolId: baseId, workspaceId })
saved++
} else if (existingTool[0].userId === userId) {
// Update existing tool if it belongs to the user
} else {
// Update existing tool in workspace (workspace members can update)
await tx
.update(customTools)
.set({
@@ -154,16 +163,10 @@ export async function persistCustomToolsToDatabase(
code: tool.code,
updatedAt: nowTime,
})
.where(eq(customTools.id, baseId))
.where(and(eq(customTools.id, baseId), eq(customTools.workspaceId, workspaceId)))
logger.info(`Updated custom tool: ${tool.title}`, { toolId: baseId })
logger.info(`Updated custom tool: ${tool.title}`, { toolId: baseId, workspaceId })
saved++
} else {
// Tool exists but belongs to different user - skip
logger.warn(`Skipping custom tool - belongs to different user: ${tool.title}`, {
toolId: baseId,
})
errors.push(`Tool ${tool.title} belongs to a different user`)
}
} catch (error) {
const errorMsg = `Failed to persist tool ${tool.title}: ${error instanceof Error ? error.message : String(error)}`
@@ -186,6 +189,7 @@ export async function persistCustomToolsToDatabase(
*/
export async function extractAndPersistCustomTools(
workflowState: any,
workspaceId: string | null,
userId: string
): Promise<{ saved: number; errors: string[] }> {
const customToolsList = extractCustomToolsFromWorkflowState(workflowState)
@@ -197,7 +201,8 @@ export async function extractAndPersistCustomTools(
logger.info(`Found ${customToolsList.length} custom tool(s) to persist`, {
tools: customToolsList.map((t) => t.title),
workspaceId,
})
return await persistCustomToolsToDatabase(customToolsList, userId)
return await persistCustomToolsToDatabase(customToolsList, workspaceId, userId)
}

View File

@@ -1,219 +1,235 @@
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { devtools } from 'zustand/middleware'
import { createLogger } from '@/lib/logs/console/logger'
import type { CustomToolsStore } from '@/stores/custom-tools/types'
import type { CustomToolsState, CustomToolsStore } from './types'
const logger = createLogger('CustomToolsStore')
const API_ENDPOINT = '/api/tools/custom'
class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.status = status
this.name = 'ApiError'
}
}
const initialState: CustomToolsState = {
tools: [],
isLoading: false,
error: null,
}
export const useCustomToolsStore = create<CustomToolsStore>()(
devtools(
persist(
(set, get) => ({
tools: {},
isLoading: false,
error: null,
(set, get) => ({
...initialState,
// Load tools from server
loadCustomTools: async () => {
try {
set({ isLoading: true, error: null })
logger.info('Loading custom tools from server')
fetchTools: async (workspaceId: string) => {
set({ isLoading: true, error: null })
const response = await fetch(API_ENDPOINT)
try {
logger.info(`Fetching custom tools for workspace ${workspaceId}`)
if (!response.ok) {
throw new Error(`Failed to load custom tools: ${response.statusText}`)
}
const response = await fetch(`${API_ENDPOINT}?workspaceId=${workspaceId}`)
const { data } = await response.json()
if (!Array.isArray(data)) {
throw new Error('Invalid response format')
}
// Filter and validate tools, skipping invalid ones instead of throwing errors
const validTools = data.filter((tool, index) => {
if (!tool || typeof tool !== 'object') {
logger.warn(`Skipping invalid tool at index ${index}: not an object`)
return false
}
if (!tool.id || typeof tool.id !== 'string') {
logger.warn(`Skipping invalid tool at index ${index}: missing or invalid id`)
return false
}
if (!tool.title || typeof tool.title !== 'string') {
logger.warn(`Skipping invalid tool at index ${index}: missing or invalid title`)
return false
}
if (!tool.schema || typeof tool.schema !== 'object') {
logger.warn(`Skipping invalid tool at index ${index}: missing or invalid schema`)
return false
}
// Make code field optional - default to empty string if missing
if (!tool.code || typeof tool.code !== 'string') {
logger.warn(`Tool at index ${index} missing code field, defaulting to empty string`)
tool.code = ''
}
return true
})
// Transform to local format and set
const transformedTools = validTools.reduce(
(acc, tool) => ({
...acc,
[tool.id]: tool,
}),
{}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(
errorData.error || `Failed to fetch custom tools: ${response.statusText}`
)
set({
tools: transformedTools,
isLoading: false,
})
} catch (error) {
logger.error('Error loading custom tools:', error)
set({
error: error instanceof Error ? error.message : 'Unknown error',
isLoading: false,
})
}
},
// Save tools to server
sync: async () => {
try {
set({ isLoading: true, error: null })
const { data } = await response.json()
const tools = Object.values(get().tools)
logger.info(`Syncing ${tools.length} custom tools with server`)
if (!Array.isArray(data)) {
throw new Error('Invalid response format')
}
// Log details of tools being synced for debugging
if (tools.length > 0) {
logger.info(
'Custom tools to sync:',
tools.map((tool) => ({
id: tool.id,
title: tool.title,
functionName: tool.schema?.function?.name || 'unknown',
}))
)
// Filter and validate tools
const validTools = data.filter((tool, index) => {
if (!tool || typeof tool !== 'object') {
logger.warn(`Skipping invalid tool at index ${index}: not an object`)
return false
}
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tools }),
})
if (!response.ok) {
// Try to get more detailed error information
try {
const errorData = await response.json()
throw new Error(
`Failed to sync custom tools: ${response.statusText}. ${errorData.error || ''}`
)
} catch (_parseError) {
throw new Error(`Failed to sync custom tools: ${response.statusText}`)
}
if (!tool.id || typeof tool.id !== 'string') {
logger.warn(`Skipping invalid tool at index ${index}: missing or invalid id`)
return false
}
set({ isLoading: false })
logger.info('Successfully synced custom tools with server')
} catch (error) {
logger.error('Error syncing custom tools:', error)
set({
error: error instanceof Error ? error.message : 'Unknown error',
isLoading: false,
})
}
},
addTool: (tool) => {
const id = crypto.randomUUID()
const newTool = {
...tool,
id,
createdAt: new Date().toISOString(),
}
set((state) => ({
tools: {
...state.tools,
[id]: newTool,
},
}))
// Sync with server
get()
.sync()
.catch((error) => {
logger.error('Error syncing after adding tool:', error)
})
return id
},
updateTool: (id, updates) => {
const tool = get().tools[id]
if (!tool) return false
const updatedTool = {
...tool,
...updates,
updatedAt: new Date().toISOString(),
}
set((state) => ({
tools: {
...state.tools,
[id]: updatedTool,
},
}))
// Sync with server
get()
.sync()
.catch((error) => {
logger.error('Error syncing after updating tool:', error)
})
return true
},
removeTool: (id) => {
set((state) => {
const newTools = { ...state.tools }
delete newTools[id]
return { tools: newTools }
if (!tool.title || typeof tool.title !== 'string') {
logger.warn(`Skipping invalid tool at index ${index}: missing or invalid title`)
return false
}
if (!tool.schema || typeof tool.schema !== 'object') {
logger.warn(`Skipping invalid tool at index ${index}: missing or invalid schema`)
return false
}
if (!tool.code || typeof tool.code !== 'string') {
logger.warn(`Tool at index ${index} missing code field, defaulting to empty string`)
tool.code = ''
}
return true
})
// Sync with server
get()
.sync()
.catch((error) => {
logger.error('Error syncing after removing tool:', error)
})
},
set({
tools: validTools,
isLoading: false,
})
getTool: (id) => {
return get().tools[id]
},
logger.info(`Fetched ${validTools.length} custom tools for workspace ${workspaceId}`)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch tools'
logger.error('Error fetching custom tools:', error)
set({
error: errorMessage,
isLoading: false,
})
}
},
getAllTools: () => {
return Object.values(get().tools)
},
}),
{
name: 'custom-tools-store',
onRehydrateStorage: () => {
return (state) => {
// We'll load via the central initialization system in stores/index.ts
// No need for a setTimeout here
logger.info('Store rehydrated from localStorage')
createTool: async (workspaceId: string, tool) => {
set({ isLoading: true, error: null })
try {
logger.info(`Creating custom tool: ${tool.title} in workspace ${workspaceId}`)
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tools: [
{
title: tool.title,
schema: tool.schema,
code: tool.code,
},
],
workspaceId,
}),
})
const data = await response.json()
if (!response.ok) {
throw new ApiError(data.error || 'Failed to create tool', response.status)
}
},
}
)
if (!data.data || !Array.isArray(data.data)) {
throw new Error('Invalid API response: missing tools data')
}
set({ tools: data.data, isLoading: false })
const createdTool = get().tools.find((t) => t.title === tool.title)
if (!createdTool) {
throw new Error('Failed to retrieve created tool')
}
logger.info(`Created custom tool: ${createdTool.id}`)
return createdTool
} catch (error) {
logger.error('Error creating custom tool:', error)
set({ isLoading: false })
throw error
}
},
updateTool: async (workspaceId: string, id: string, updates) => {
set({ isLoading: true, error: null })
try {
const tool = get().tools.find((t) => t.id === id)
if (!tool) {
throw new Error('Tool not found')
}
logger.info(`Updating custom tool: ${id} in workspace ${workspaceId}`)
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tools: [
{
id,
title: updates.title ?? tool.title,
schema: updates.schema ?? tool.schema,
code: updates.code ?? tool.code,
},
],
workspaceId,
}),
})
const data = await response.json()
if (!response.ok) {
throw new ApiError(data.error || 'Failed to update tool', response.status)
}
if (!data.data || !Array.isArray(data.data)) {
throw new Error('Invalid API response: missing tools data')
}
set({ tools: data.data, isLoading: false })
logger.info(`Updated custom tool: ${id}`)
} catch (error) {
logger.error('Error updating custom tool:', error)
set({ isLoading: false })
throw error
}
},
deleteTool: async (workspaceId: string | null, id: string) => {
set({ isLoading: true, error: null })
try {
logger.info(`Deleting custom tool: ${id}`)
// Build URL with optional workspaceId (for user-scoped tools)
const url = workspaceId
? `${API_ENDPOINT}?id=${id}&workspaceId=${workspaceId}`
: `${API_ENDPOINT}?id=${id}`
const response = await fetch(url, {
method: 'DELETE',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to delete tool')
}
set((state) => ({
tools: state.tools.filter((tool) => tool.id !== id),
isLoading: false,
}))
logger.info(`Deleted custom tool: ${id}`)
} catch (error) {
logger.error('Error deleting custom tool:', error)
set({ isLoading: false })
throw error
}
},
getTool: (id: string) => {
return get().tools.find((tool) => tool.id === id)
},
getAllTools: () => {
return get().tools
},
clearError: () => set({ error: null }),
reset: () => set(initialState),
}),
{
name: 'custom-tools-store',
}
)
)

View File

@@ -13,6 +13,8 @@ export interface CustomToolSchema {
export interface CustomToolDefinition {
id: string
workspaceId: string | null
userId: string | null
title: string
schema: CustomToolSchema
code: string
@@ -20,22 +22,30 @@ export interface CustomToolDefinition {
updatedAt?: string
}
export interface CustomToolsStore {
tools: Record<string, CustomToolDefinition>
export interface CustomToolsState {
tools: CustomToolDefinition[]
isLoading: boolean
error: string | null
}
// CRUD operations
addTool: (tool: Omit<CustomToolDefinition, 'id' | 'createdAt' | 'updatedAt'>) => string
export interface CustomToolsActions {
fetchTools: (workspaceId: string) => Promise<void>
createTool: (
workspaceId: string,
tool: Omit<CustomToolDefinition, 'id' | 'workspaceId' | 'userId' | 'createdAt' | 'updatedAt'>
) => Promise<CustomToolDefinition>
updateTool: (
workspaceId: string,
id: string,
updates: Partial<Omit<CustomToolDefinition, 'id' | 'createdAt' | 'updatedAt'>>
) => boolean
removeTool: (id: string) => void
updates: Partial<
Omit<CustomToolDefinition, 'id' | 'workspaceId' | 'userId' | 'createdAt' | 'updatedAt'>
>
) => Promise<void>
deleteTool: (workspaceId: string | null, id: string) => Promise<void>
getTool: (id: string) => CustomToolDefinition | undefined
getAllTools: () => CustomToolDefinition[]
// Server sync operations
loadCustomTools: () => Promise<void>
sync: () => Promise<void>
clearError: () => void
reset: () => void
}
export interface CustomToolsStore extends CustomToolsState, CustomToolsActions {}

View File

@@ -37,9 +37,6 @@ async function initializeApplication(): Promise<void> {
// Load environment variables directly from DB
await useEnvironmentStore.getState().loadEnvironmentVariables()
// Load custom tools from server
await useCustomToolsStore.getState().loadCustomTools()
// Mark data as initialized only after sync managers have loaded data from DB
dataInitialized = true
@@ -221,7 +218,7 @@ export const resetAllStores = () => {
useExecutionStore.getState().reset()
useConsoleStore.setState({ entries: [], isOpen: false })
useCopilotStore.setState({ messages: [], isSendingMessage: false, error: null })
useCustomToolsStore.setState({ tools: {} })
useCustomToolsStore.getState().reset()
// Variables store has no tracking to reset; registry hydrates
useSubscriptionStore.getState().reset() // Reset subscription store
}

View File

@@ -365,7 +365,22 @@ async function getCustomTool(
url.searchParams.append('workflowId', workflowId)
}
const response = await fetch(url.toString())
// For server-side calls (during workflow execution), use internal JWT token
const headers: Record<string, string> = {}
if (typeof window === 'undefined') {
try {
const { generateInternalToken } = await import('@/lib/auth/internal')
const internalToken = await generateInternalToken()
headers.Authorization = `Bearer ${internalToken}`
} catch (error) {
logger.warn('Failed to generate internal token for custom tools fetch', { error })
// Continue without token - will fail auth and be reported upstream
}
}
const response = await fetch(url.toString(), {
headers,
})
if (!response.ok) {
logger.error(`Failed to fetch custom tools: ${response.statusText}`)

View File

@@ -0,0 +1,8 @@
ALTER TABLE "custom_tools" DROP CONSTRAINT "custom_tools_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "custom_tools" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint
-- Add workspace_id as nullable (existing tools will have null, new tools will be workspace-scoped)
ALTER TABLE "custom_tools" ADD COLUMN "workspace_id" text;--> statement-breakpoint
ALTER TABLE "custom_tools" ADD CONSTRAINT "custom_tools_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "custom_tools" ADD CONSTRAINT "custom_tools_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "custom_tools_workspace_id_idx" ON "custom_tools" USING btree ("workspace_id");

File diff suppressed because it is too large Load Diff

View File

@@ -729,6 +729,13 @@
"when": 1761848118406,
"tag": "0104_orange_shinobi_shaw",
"breakpoints": true
},
{
"idx": 105,
"version": "7",
"when": 1761860659858,
"tag": "0105_glamorous_wrecking_crew",
"breakpoints": true
}
]
}

View File

@@ -576,17 +576,22 @@ export const userStats = pgTable('user_stats', {
billingBlocked: boolean('billing_blocked').notNull().default(false),
})
export const customTools = pgTable('custom_tools', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
schema: json('schema').notNull(),
code: text('code').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export const customTools = pgTable(
'custom_tools',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }),
userId: text('user_id').references(() => user.id, { onDelete: 'set null' }),
title: text('title').notNull(),
schema: json('schema').notNull(),
code: text('code').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceIdIdx: index('custom_tools_workspace_id_idx').on(table.workspaceId),
})
)
export const subscription = pgTable(
'subscription',