mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
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:
committed by
GitHub
parent
3b901b33d1
commit
a072e6d1d8
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
8
packages/db/migrations/0105_glamorous_wrecking_crew.sql
Normal file
8
packages/db/migrations/0105_glamorous_wrecking_crew.sql
Normal 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");
|
||||
7276
packages/db/migrations/meta/0105_snapshot.json
Normal file
7276
packages/db/migrations/meta/0105_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user