Remove dup code from tool calls

This commit is contained in:
Siddharth Ganesan
2026-02-24 16:59:40 -08:00
parent 87f5c464d9
commit eccad2a8ce
12 changed files with 537 additions and 474 deletions

View File

@@ -1,4 +1,8 @@
import { db } from '@sim/db'
import { apiKey } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import {
decryptApiKey,
encryptApiKey,
@@ -210,6 +214,52 @@ export async function getEncryptedApiKeyLast4(encryptedKey: string): Promise<str
* @param apiKey - The API key to validate
* @returns boolean - true if the format appears valid
*/
export function isValidApiKeyFormat(apiKey: string): boolean {
return typeof apiKey === 'string' && apiKey.length > 10 && apiKey.length < 200
export function isValidApiKeyFormat(apiKeyValue: string): boolean {
return typeof apiKeyValue === 'string' && apiKeyValue.length > 10 && apiKeyValue.length < 200
}
export async function createWorkspaceApiKey(params: {
workspaceId: string
userId: string
name: string
}) {
const existingKey = await db
.select({ id: apiKey.id })
.from(apiKey)
.where(
and(
eq(apiKey.workspaceId, params.workspaceId),
eq(apiKey.name, params.name),
eq(apiKey.type, 'workspace')
)
)
.limit(1)
if (existingKey.length > 0) {
throw new Error(
`A workspace API key named "${params.name}" already exists. Choose a different name.`
)
}
const { key: plainKey, encryptedKey } = await createApiKey(true)
if (!encryptedKey) {
throw new Error('Failed to encrypt API key for storage')
}
const [newKey] = await db
.insert(apiKey)
.values({
id: nanoid(),
workspaceId: params.workspaceId,
userId: params.userId,
createdBy: params.userId,
name: params.name,
key: encryptedKey,
type: 'workspace',
createdAt: new Date(),
updatedAt: new Date(),
})
.returning({ id: apiKey.id, name: apiKey.name, createdAt: apiKey.createdAt })
return { id: newKey.id, name: newKey.name, key: plainKey, createdAt: newKey.createdAt }
}

View File

@@ -1,8 +1,12 @@
import { db } from '@sim/db'
import { permissions, workflow, workspace } from '@sim/db/schema'
import { and, asc, desc, eq, inArray } from 'drizzle-orm'
import { permissions, workspace } from '@sim/db/schema'
import { and, desc, eq } from 'drizzle-orm'
import {
authorizeWorkflowByWorkspacePermission,
getWorkflowById,
} from '@/lib/workflows/utils'
type WorkflowRecord = typeof workflow.$inferSelect
type WorkflowRecord = NonNullable<Awaited<ReturnType<typeof getWorkflowById>>>
export async function ensureWorkflowAccess(
workflowId: string,
@@ -11,37 +15,21 @@ export async function ensureWorkflowAccess(
workflow: WorkflowRecord
workspaceId?: string | null
}> {
const [workflowRecord] = await db
.select()
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord) {
const result = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'read',
})
if (!result.workflow) {
throw new Error(`Workflow ${workflowId} not found`)
}
if (!workflowRecord.workspaceId) {
throw new Error(
'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot be accessed.'
)
if (!result.allowed) {
throw new Error(result.message || 'Unauthorized workflow access')
}
const [permissionRow] = await db
.select({ permissionType: permissions.permissionType })
.from(permissions)
.where(
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workflowRecord.workspaceId),
eq(permissions.userId, userId)
)
)
.limit(1)
if (permissionRow) {
return { workflow: workflowRecord, workspaceId: workflowRecord.workspaceId }
}
throw new Error('Unauthorized workflow access')
return { workflow: result.workflow, workspaceId: result.workflow.workspaceId }
}
export async function getDefaultWorkspaceId(userId: string): Promise<string> {
@@ -96,35 +84,3 @@ export async function ensureWorkspaceAccess(
}
}
export async function getAccessibleWorkflowsForUser(
userId: string,
options?: { workspaceId?: string; folderId?: string }
) {
const workspaceIds = await db
.select({ entityId: permissions.entityId })
.from(permissions)
.where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace')))
const workspaceIdList = workspaceIds.map((row) => row.entityId)
if (workspaceIdList.length === 0) {
return []
}
if (options?.workspaceId && !workspaceIdList.includes(options.workspaceId)) {
return []
}
const workflowConditions = [inArray(workflow.workspaceId, workspaceIdList)]
if (options?.workspaceId) {
workflowConditions.push(eq(workflow.workspaceId, options.workspaceId))
}
if (options?.folderId) {
workflowConditions.push(eq(workflow.folderId, options.folderId))
}
return db
.select()
.from(workflow)
.where(and(...workflowConditions))
.orderBy(asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id))
}

View File

@@ -1,8 +1,6 @@
import { db } from '@sim/db'
import { customTools, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, isNull, or } from 'drizzle-orm'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { getWorkflowById } from '@/lib/workflows/utils'
import type {
ExecutionContext,
ToolCallResult,
@@ -12,7 +10,12 @@ import { routeExecution } from '@/lib/copilot/tools/server/router'
import { env } from '@/lib/core/config/env'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import { upsertCustomTools } from '@/lib/workflows/custom-tools/operations'
import {
deleteCustomTool,
getCustomToolById,
listCustomTools,
upsertCustomTools,
} from '@/lib/workflows/custom-tools/operations'
import { getTool, resolveToolId } from '@/tools/utils'
import {
executeCheckDeploymentStatus,
@@ -123,22 +126,10 @@ async function executeManageCustomTool(
try {
if (operation === 'list') {
const toolsForUser = workspaceId
? await db
.select()
.from(customTools)
.where(
or(
eq(customTools.workspaceId, workspaceId),
and(isNull(customTools.workspaceId), eq(customTools.userId, context.userId))
)
)
.orderBy(desc(customTools.createdAt))
: await db
.select()
.from(customTools)
.where(and(isNull(customTools.workspaceId), eq(customTools.userId, context.userId)))
.orderBy(desc(customTools.createdAt))
const toolsForUser = await listCustomTools({
userId: context.userId,
workspaceId,
})
return {
success: true,
@@ -171,13 +162,7 @@ async function executeManageCustomTool(
}
const resultTools = await upsertCustomTools({
tools: [
{
title,
schema: params.schema,
code: params.code,
},
],
tools: [{ title, schema: params.schema, code: params.code }],
workspaceId,
userId: context.userId,
})
@@ -212,28 +197,11 @@ async function executeManageCustomTool(
}
}
const workspaceTool = await db
.select()
.from(customTools)
.where(and(eq(customTools.id, params.toolId), eq(customTools.workspaceId, workspaceId)))
.limit(1)
const legacyTool =
workspaceTool.length === 0
? await db
.select()
.from(customTools)
.where(
and(
eq(customTools.id, params.toolId),
isNull(customTools.workspaceId),
eq(customTools.userId, context.userId)
)
)
.limit(1)
: []
const existing = workspaceTool[0] || legacyTool[0]
const existing = await getCustomToolById({
toolId: params.toolId,
userId: context.userId,
workspaceId,
})
if (!existing) {
return { success: false, error: `Custom tool not found: ${params.toolId}` }
}
@@ -243,14 +211,7 @@ async function executeManageCustomTool(
const title = params.title || mergedSchema.function?.name || existing.title
await upsertCustomTools({
tools: [
{
id: params.toolId,
title,
schema: mergedSchema,
code: mergedCode,
},
],
tools: [{ id: params.toolId, title, schema: mergedSchema, code: mergedCode }],
workspaceId,
userId: context.userId,
})
@@ -272,31 +233,11 @@ async function executeManageCustomTool(
return { success: false, error: "'toolId' is required for operation 'delete'" }
}
const workspaceDelete =
workspaceId != null
? await db
.delete(customTools)
.where(
and(eq(customTools.id, params.toolId), eq(customTools.workspaceId, workspaceId))
)
.returning({ id: customTools.id })
: []
const legacyDelete =
workspaceDelete.length === 0
? await db
.delete(customTools)
.where(
and(
eq(customTools.id, params.toolId),
isNull(customTools.workspaceId),
eq(customTools.userId, context.userId)
)
)
.returning({ id: customTools.id })
: []
const deleted = workspaceDelete[0] || legacyDelete[0]
const deleted = await deleteCustomTool({
toolId: params.toolId,
userId: context.userId,
workspaceId,
})
if (!deleted) {
return { success: false, error: `Custom tool not found: ${params.toolId}` }
}
@@ -587,12 +528,8 @@ export async function prepareExecutionContext(
userId: string,
workflowId: string
): Promise<ExecutionContext> {
const workflowResult = await db
.select({ workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
const workspaceId = workflowResult[0]?.workspaceId ?? undefined
const wf = await getWorkflowById(workflowId)
const workspaceId = wf?.workspaceId ?? undefined
const decryptedEnvVars = await getEffectiveDecryptedEnv(userId, workspaceId)

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { account, workflow } from '@sim/db/schema'
import { account } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import type {
ExecutionContext,
@@ -8,6 +8,7 @@ import type {
} from '@/lib/copilot/orchestrator/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import { getWorkflowById } from '@/lib/workflows/utils'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
import { executeTool } from '@/tools'
@@ -27,12 +28,8 @@ export async function executeIntegrationToolDirect(
let workspaceId = context.workspaceId
if (!workspaceId && workflowId) {
const workflowResult = await db
.select({ workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
workspaceId = workflowResult[0]?.workspaceId ?? undefined
const wf = await getWorkflowById(workflowId)
workspaceId = wf?.workspaceId ?? undefined
}
const decryptedEnvVars =

View File

@@ -1,19 +1,22 @@
import crypto from 'crypto'
import { db } from '@sim/db'
import { apiKey, workflow, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, max } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { createApiKey } from '@/lib/api-key/auth'
import { createWorkspaceApiKey } from '@/lib/api-key/auth'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
import {
getExecutionState,
getLatestExecutionState,
} from '@/lib/workflows/executor/execution-state'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import {
createFolderRecord,
createWorkflowRecord,
deleteFolderRecord,
deleteWorkflowRecord,
setWorkflowVariables,
updateFolderRecord,
updateWorkflowRecord,
} from '@/lib/workflows/utils'
import { ensureWorkflowAccess, ensureWorkspaceAccess, getDefaultWorkspaceId } from '../access'
import type {
CreateFolderParams,
@@ -58,46 +61,21 @@ export async function executeCreateWorkflow(
await ensureWorkspaceAccess(workspaceId, context.userId, true)
const workflowId = crypto.randomUUID()
const now = new Date()
const folderCondition = folderId ? eq(workflow.folderId, folderId) : isNull(workflow.folderId)
const [maxResult] = await db
.select({ maxOrder: max(workflow.sortOrder) })
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), folderCondition))
const sortOrder = (maxResult?.maxOrder ?? 0) + 1
await db.insert(workflow).values({
id: workflowId,
const result = await createWorkflowRecord({
userId: context.userId,
workspaceId,
folderId,
sortOrder,
name,
description,
color: '#3972F6',
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false,
runCount: 0,
variables: {},
folderId,
})
const { workflowState } = buildDefaultWorkflowArtifacts()
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState)
if (!saveResult.success) {
throw new Error(saveResult.error || 'Failed to save workflow state')
}
return {
success: true,
output: {
workflowId,
workflowName: name,
workspaceId,
folderId,
workflowId: result.workflowId,
workflowName: result.name,
workspaceId: result.workspaceId,
folderId: result.folderId,
},
}
} catch (error) {
@@ -123,30 +101,14 @@ export async function executeCreateFolder(
await ensureWorkspaceAccess(workspaceId, context.userId, true)
const [maxResult] = await db
.select({ maxOrder: max(workflowFolder.sortOrder) })
.from(workflowFolder)
.where(
and(
eq(workflowFolder.workspaceId, workspaceId),
parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId)
)
)
const sortOrder = (maxResult?.maxOrder ?? 0) + 1
const folderId = crypto.randomUUID()
await db.insert(workflowFolder).values({
id: folderId,
const result = await createFolderRecord({
userId: context.userId,
workspaceId,
parentId,
name,
sortOrder,
createdAt: new Date(),
updatedAt: new Date(),
parentId,
})
return { success: true, output: { folderId, name, workspaceId, parentId } }
return { success: true, output: result }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
@@ -292,10 +254,7 @@ export async function executeSetGlobalWorkflowVariables(
const nextVarsRecord = Object.fromEntries(Object.values(byName).map((v) => [String(v.id), v]))
await db
.update(workflow)
.set({ variables: nextVarsRecord, updatedAt: new Date() })
.where(eq(workflow.id, workflowId))
await setWorkflowVariables(workflowId, nextVarsRecord)
return { success: true, output: { updated: Object.values(byName).length } }
} catch (error) {
@@ -321,11 +280,7 @@ export async function executeRenameWorkflow(
}
await ensureWorkflowAccess(workflowId, context.userId)
await db
.update(workflow)
.set({ name, updatedAt: new Date() })
.where(eq(workflow.id, workflowId))
await updateWorkflowRecord(workflowId, { name })
return { success: true, output: { workflowId, name } }
} catch (error) {
@@ -344,13 +299,8 @@ export async function executeMoveWorkflow(
}
await ensureWorkflowAccess(workflowId, context.userId)
const folderId = params.folderId || null
await db
.update(workflow)
.set({ folderId, updatedAt: new Date() })
.where(eq(workflow.id, workflowId))
await updateWorkflowRecord(workflowId, { folderId })
return { success: true, output: { workflowId, folderId } }
} catch (error) {
@@ -374,10 +324,7 @@ export async function executeMoveFolder(
return { success: false, error: 'A folder cannot be moved into itself' }
}
await db
.update(workflowFolder)
.set({ parentId, updatedAt: new Date() })
.where(eq(workflowFolder.id, folderId))
await updateFolderRecord(folderId, { parentId })
return { success: true, output: { folderId, parentId } }
} catch (error) {
@@ -452,51 +399,18 @@ export async function executeGenerateApiKey(
const workspaceId = params.workspaceId || (await getDefaultWorkspaceId(context.userId))
await ensureWorkspaceAccess(workspaceId, context.userId, true)
const existingKey = await db
.select({ id: apiKey.id })
.from(apiKey)
.where(
and(
eq(apiKey.workspaceId, workspaceId),
eq(apiKey.name, name),
eq(apiKey.type, 'workspace')
)
)
.limit(1)
if (existingKey.length > 0) {
return {
success: false,
error: `A workspace API key named "${name}" already exists. Choose a different name.`,
}
}
const { key: plainKey, encryptedKey } = await createApiKey(true)
if (!encryptedKey) {
return { success: false, error: 'Failed to encrypt API key for storage' }
}
const [newKey] = await db
.insert(apiKey)
.values({
id: nanoid(),
workspaceId,
userId: context.userId,
createdBy: context.userId,
name,
key: encryptedKey,
type: 'workspace',
createdAt: new Date(),
updatedAt: new Date(),
})
.returning({ id: apiKey.id, name: apiKey.name, createdAt: apiKey.createdAt })
const newKey = await createWorkspaceApiKey({
workspaceId,
userId: context.userId,
name,
})
return {
success: true,
output: {
id: newKey.id,
name: newKey.name,
key: plainKey,
key: newKey.key,
workspaceId,
message:
'API key created successfully. Copy this key now — it will not be shown again. Use this key in the x-api-key header when calling workflow API endpoints.',
@@ -580,7 +494,7 @@ export async function executeUpdateWorkflow(
return { success: false, error: 'workflowId is required' }
}
const updates: Record<string, unknown> = { updatedAt: new Date() }
const updates: { name?: string; description?: string } = {}
if (typeof params.name === 'string') {
const name = params.name.trim()
@@ -596,17 +510,16 @@ export async function executeUpdateWorkflow(
updates.description = params.description
}
if (Object.keys(updates).length <= 1) {
if (Object.keys(updates).length === 0) {
return { success: false, error: 'At least one of name or description is required' }
}
await ensureWorkflowAccess(workflowId, context.userId)
await db.update(workflow).set(updates).where(eq(workflow.id, workflowId))
await updateWorkflowRecord(workflowId, updates)
return {
success: true,
output: { workflowId, ...updates, updatedAt: undefined },
output: { workflowId, ...updates },
}
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
@@ -624,8 +537,7 @@ export async function executeDeleteWorkflow(
}
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
await db.delete(workflow).where(eq(workflow.id, workflowId))
await deleteWorkflowRecord(workflowId)
return {
success: true,
@@ -653,10 +565,7 @@ export async function executeRenameFolder(
return { success: false, error: 'Folder name must be 200 characters or less' }
}
await db
.update(workflowFolder)
.set({ name, updatedAt: new Date() })
.where(eq(workflowFolder.id, folderId))
await updateFolderRecord(folderId, { name })
return { success: true, output: { folderId, name } }
} catch (error) {
@@ -674,32 +583,11 @@ export async function executeDeleteFolder(
return { success: false, error: 'folderId is required' }
}
// Get the folder to find its parent
const [folder] = await db
.select({ parentId: workflowFolder.parentId })
.from(workflowFolder)
.where(eq(workflowFolder.id, folderId))
.limit(1)
if (!folder) {
const deleted = await deleteFolderRecord(folderId)
if (!deleted) {
return { success: false, error: 'Folder not found' }
}
// Move child workflows to parent folder
await db
.update(workflow)
.set({ folderId: folder.parentId, updatedAt: new Date() })
.where(eq(workflow.folderId, folderId))
// Move child folders to parent folder
await db
.update(workflowFolder)
.set({ parentId: folder.parentId, updatedAt: new Date() })
.where(eq(workflowFolder.parentId, folderId))
// Delete the folder
await db.delete(workflowFolder).where(eq(workflowFolder.id, folderId))
return { success: true, output: { folderId, deleted: true } }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }

View File

@@ -1,8 +1,6 @@
import { db } from '@sim/db'
import { customTools, permissions, workflow, workflowFolder, workspace } from '@sim/db/schema'
import { and, asc, desc, eq, isNull, or } from 'drizzle-orm'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types'
import { formatNormalizedWorkflowForCopilot } from '@/lib/copilot/tools/shared/workflow-utils'
import { listCustomTools } from '@/lib/workflows/custom-tools/operations'
import { mcpService } from '@/lib/mcp/service'
import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace'
import { getEffectiveBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs'
@@ -11,6 +9,8 @@ import {
loadDeployedWorkflowState,
loadWorkflowFromNormalizedTables,
} from '@/lib/workflows/persistence/utils'
import { getWorkflowById, listFolders } from '@/lib/workflows/utils'
import { listUserWorkspaces } from '@/lib/workspaces/utils'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { getBlock } from '@/blocks/registry'
import { normalizeName } from '@/executor/constants'
@@ -32,25 +32,9 @@ export async function executeListUserWorkspaces(
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workspaces = await db
.select({
workspaceId: workspace.id,
workspaceName: workspace.name,
ownerId: workspace.ownerId,
permissionType: permissions.permissionType,
})
.from(permissions)
.innerJoin(workspace, eq(permissions.entityId, workspace.id))
.where(and(eq(permissions.userId, context.userId), eq(permissions.entityType, 'workspace')))
.orderBy(desc(workspace.createdAt))
const workspaces = await listUserWorkspaces(context.userId)
const output = workspaces.map((row) => ({
workspaceId: row.workspaceId,
workspaceName: row.workspaceName,
role: row.ownerId === context.userId ? 'owner' : row.permissionType,
}))
return { success: true, output: { workspaces: output } }
return { success: true, output: { workspaces } }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
@@ -66,16 +50,7 @@ export async function executeListFolders(
await ensureWorkspaceAccess(workspaceId, context.userId, false)
const folders = await db
.select({
folderId: workflowFolder.id,
folderName: workflowFolder.name,
parentId: workflowFolder.parentId,
sortOrder: workflowFolder.sortOrder,
})
.from(workflowFolder)
.where(eq(workflowFolder.workspaceId, workspaceId))
.orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt))
const folders = await listFolders(workspaceId)
return {
success: true,
@@ -125,15 +100,10 @@ export async function executeGetWorkflowData(
if (!workspaceId) {
return { success: false, error: 'workspaceId is required' }
}
const conditions = [
eq(customTools.workspaceId, workspaceId),
and(eq(customTools.userId, context.userId), isNull(customTools.workspaceId)),
]
const toolsRows = await db
.select()
.from(customTools)
.where(or(...conditions))
.orderBy(desc(customTools.createdAt))
const toolsRows = await listCustomTools({
userId: context.userId,
workspaceId,
})
const customToolsData = toolsRows.map((tool) => {
const schema = tool.schema as Record<string, unknown> | null
@@ -419,11 +389,7 @@ export async function executeGetBlockUpstreamReferences(
async function getWorkflowVariablesForTool(
workflowId: string
): Promise<Array<{ id: string; name: string; type: string; tag: string }>> {
const [workflowRecord] = await db
.select({ variables: workflow.variables })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
const workflowRecord = await getWorkflowById(workflowId)
const variablesRecord = (workflowRecord?.variables as Record<string, unknown>) || {}
return Object.values(variablesRecord)

View File

@@ -3,6 +3,7 @@ import { docsEmbeddings } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { sql } from 'drizzle-orm'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { generateSearchEmbedding } from '@/lib/knowledge/embeddings'
interface DocsSearchParams {
query: string
@@ -23,7 +24,6 @@ export const searchDocumentationServerTool: BaseServerTool<DocsSearchParams, any
const similarityThreshold = threshold ?? DEFAULT_DOCS_SIMILARITY_THRESHOLD
const { generateSearchEmbedding } = await import('@/lib/knowledge/embeddings')
const queryEmbedding = await generateSearchEmbedding(query)
if (!queryEmbedding || queryEmbedding.length === 0) {
return { results: [], query, totalResults: 0 }

View File

@@ -1,14 +1,11 @@
import { db } from '@sim/db'
import { credential, environment, workflow, workspaceEnvironment } from '@sim/db/schema'
import { credential, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { z } from 'zod'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import {
syncPersonalEnvCredentialsForUser,
syncWorkspaceEnvCredentials,
} from '@/lib/credentials/environment'
import { upsertPersonalEnvVars, upsertWorkspaceEnvVars } from '@/lib/environment/utils'
import { getWorkflowById } from '@/lib/workflows/utils'
interface SetEnvironmentVariablesParams {
variables: Record<string, any> | Array<{ name: string; value: string }>
@@ -65,11 +62,7 @@ export const setEnvironmentVariablesServerTool: BaseServerTool<SetEnvironmentVar
let resolvedWorkspaceId: string | null = null
if (requestedKeys.length > 0 && workflowId) {
const [wf] = await db
.select({ workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
const wf = await getWorkflowById(workflowId)
if (wf?.workspaceId) {
resolvedWorkspaceId = wf.workspaceId
@@ -101,112 +94,15 @@ export const setEnvironmentVariablesServerTool: BaseServerTool<SetEnvironmentVar
}
}
const added: string[] = []
const updated: string[] = []
const workspaceUpdated: string[] = []
if (Object.keys(personalVars).length > 0) {
const existingData = await db
.select()
.from(environment)
.where(eq(environment.userId, authenticatedUserId))
.limit(1)
const existingEncrypted = (existingData[0]?.variables as Record<string, string>) || {}
const toEncrypt: Record<string, string> = {}
for (const [key, newVal] of Object.entries(personalVars)) {
if (!(key in existingEncrypted)) {
toEncrypt[key] = newVal
added.push(key)
} else {
try {
const { decrypted } = await decryptSecret(existingEncrypted[key])
if (decrypted !== newVal) {
toEncrypt[key] = newVal
updated.push(key)
}
} catch {
toEncrypt[key] = newVal
updated.push(key)
}
}
}
const newlyEncrypted = await Object.entries(toEncrypt).reduce(
async (accP, [key, val]) => {
const acc = await accP
const { encrypted } = await encryptSecret(val)
return { ...acc, [key]: encrypted }
},
Promise.resolve({} as Record<string, string>)
)
const finalEncrypted = { ...existingEncrypted, ...newlyEncrypted }
await db
.insert(environment)
.values({
id: crypto.randomUUID(),
userId: authenticatedUserId,
variables: finalEncrypted,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [environment.userId],
set: { variables: finalEncrypted, updatedAt: new Date() },
})
await syncPersonalEnvCredentialsForUser({
userId: authenticatedUserId,
envKeys: Object.keys(finalEncrypted),
})
}
const { added, updated } = await upsertPersonalEnvVars(authenticatedUserId, personalVars)
let workspaceUpdated: string[] = []
if (Object.keys(workspaceVars).length > 0 && resolvedWorkspaceId) {
const wsRows = await db
.select()
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, resolvedWorkspaceId))
.limit(1)
const existingWsEncrypted = (wsRows[0]?.variables as Record<string, string>) || {}
const toEncryptWs: Record<string, string> = {}
for (const [key, newVal] of Object.entries(workspaceVars)) {
toEncryptWs[key] = newVal
workspaceUpdated.push(key)
}
const newlyEncryptedWs = await Object.entries(toEncryptWs).reduce(
async (accP, [key, val]) => {
const acc = await accP
const { encrypted } = await encryptSecret(val)
return { ...acc, [key]: encrypted }
},
Promise.resolve({} as Record<string, string>)
workspaceUpdated = await upsertWorkspaceEnvVars(
resolvedWorkspaceId,
workspaceVars,
authenticatedUserId
)
const mergedWs = { ...existingWsEncrypted, ...newlyEncryptedWs }
await db
.insert(workspaceEnvironment)
.values({
id: crypto.randomUUID(),
workspaceId: resolvedWorkspaceId,
variables: mergedWs,
createdAt: new Date(),
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [workspaceEnvironment.workspaceId],
set: { variables: mergedWs, updatedAt: new Date() },
})
await syncWorkspaceEnvCredentials({
workspaceId: resolvedWorkspaceId,
envKeys: Object.keys(workspaceVars),
actingUserId: authenticatedUserId,
})
}
const totalProcessed = added.length + updated.length + workspaceUpdated.length

View File

@@ -1,9 +1,14 @@
import crypto from 'crypto'
import { db } from '@sim/db'
import { environment, workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, inArray } from 'drizzle-orm'
import { decryptSecret } from '@/lib/core/security/encryption'
import { getAccessibleEnvCredentials } from '@/lib/credentials/environment'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import {
getAccessibleEnvCredentials,
syncPersonalEnvCredentialsForUser,
syncWorkspaceEnvCredentials,
} from '@/lib/credentials/environment'
const logger = createLogger('EnvironmentUtils')
@@ -177,6 +182,121 @@ export async function getPersonalAndWorkspaceEnv(
}
}
export interface EnvUpsertResult {
added: string[]
updated: string[]
}
/**
* Encrypts and upserts personal environment variables, merging with existing.
* Only overwrites keys whose decrypted value has actually changed.
*/
export async function upsertPersonalEnvVars(
userId: string,
newVars: Record<string, string>
): Promise<EnvUpsertResult> {
const added: string[] = []
const updated: string[] = []
if (Object.keys(newVars).length === 0) return { added, updated }
const existingData = await db
.select()
.from(environment)
.where(eq(environment.userId, userId))
.limit(1)
const existingEncrypted = (existingData[0]?.variables as Record<string, string>) || {}
const toEncrypt: Record<string, string> = {}
for (const [key, newVal] of Object.entries(newVars)) {
if (!(key in existingEncrypted)) {
toEncrypt[key] = newVal
added.push(key)
} else {
try {
const { decrypted } = await decryptSecret(existingEncrypted[key])
if (decrypted !== newVal) {
toEncrypt[key] = newVal
updated.push(key)
}
} catch {
toEncrypt[key] = newVal
updated.push(key)
}
}
}
const newlyEncrypted: Record<string, string> = {}
for (const [key, val] of Object.entries(toEncrypt)) {
const { encrypted } = await encryptSecret(val)
newlyEncrypted[key] = encrypted
}
const finalEncrypted = { ...existingEncrypted, ...newlyEncrypted }
await db
.insert(environment)
.values({
id: crypto.randomUUID(),
userId,
variables: finalEncrypted,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [environment.userId],
set: { variables: finalEncrypted, updatedAt: new Date() },
})
await syncPersonalEnvCredentialsForUser({ userId, envKeys: Object.keys(finalEncrypted) })
return { added, updated }
}
/**
* Encrypts and upserts workspace environment variables, merging with existing.
*/
export async function upsertWorkspaceEnvVars(
workspaceId: string,
newVars: Record<string, string>,
actingUserId: string
): Promise<string[]> {
const updatedKeys: string[] = []
if (Object.keys(newVars).length === 0) return updatedKeys
const wsRows = await db
.select()
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const existingWsEncrypted = (wsRows[0]?.variables as Record<string, string>) || {}
const newlyEncrypted: Record<string, string> = {}
for (const [key, val] of Object.entries(newVars)) {
const { encrypted } = await encryptSecret(val)
newlyEncrypted[key] = encrypted
updatedKeys.push(key)
}
const merged = { ...existingWsEncrypted, ...newlyEncrypted }
await db
.insert(workspaceEnvironment)
.values({
id: crypto.randomUUID(),
workspaceId,
variables: merged,
createdAt: new Date(),
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [workspaceEnvironment.workspaceId],
set: { variables: merged, updatedAt: new Date() },
})
await syncWorkspaceEnvCredentials({ workspaceId, envKeys: Object.keys(newVars), actingUserId })
return updatedKeys
}
export async function getEffectiveDecryptedEnv(
userId: string,
workspaceId?: string

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { customTools } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, isNull } from 'drizzle-orm'
import { and, desc, eq, isNull, or } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { generateRequestId } from '@/lib/core/utils/request'
@@ -107,3 +107,76 @@ export async function upsertCustomTools(params: {
return resultTools
})
}
export async function listCustomTools(params: {
userId: string
workspaceId?: string
}) {
const { userId, workspaceId } = params
return workspaceId
? db
.select()
.from(customTools)
.where(
or(
eq(customTools.workspaceId, workspaceId),
and(isNull(customTools.workspaceId), eq(customTools.userId, userId))
)
)
.orderBy(desc(customTools.createdAt))
: db
.select()
.from(customTools)
.where(and(isNull(customTools.workspaceId), eq(customTools.userId, userId)))
.orderBy(desc(customTools.createdAt))
}
export async function getCustomToolById(params: {
toolId: string
userId: string
workspaceId?: string
}) {
const { toolId, userId, workspaceId } = params
if (workspaceId) {
const workspaceTool = await db
.select()
.from(customTools)
.where(and(eq(customTools.id, toolId), eq(customTools.workspaceId, workspaceId)))
.limit(1)
if (workspaceTool[0]) return workspaceTool[0]
}
const legacyTool = await db
.select()
.from(customTools)
.where(
and(eq(customTools.id, toolId), isNull(customTools.workspaceId), eq(customTools.userId, userId))
)
.limit(1)
return legacyTool[0] || null
}
export async function deleteCustomTool(params: {
toolId: string
userId: string
workspaceId?: string
}): Promise<boolean> {
const { toolId, userId, workspaceId } = params
if (workspaceId) {
const workspaceDelete = await db
.delete(customTools)
.where(and(eq(customTools.id, toolId), eq(customTools.workspaceId, workspaceId)))
.returning({ id: customTools.id })
if (workspaceDelete.length > 0) return true
}
const legacyDelete = await db
.delete(customTools)
.where(
and(eq(customTools.id, toolId), isNull(customTools.workspaceId), eq(customTools.userId, userId))
)
.returning({ id: customTools.id })
return legacyDelete.length > 0
}

View File

@@ -1,9 +1,12 @@
import crypto from 'crypto'
import { db } from '@sim/db'
import { permissions, userStats, workflow as workflowTable } from '@sim/db/schema'
import { permissions, userStats, workflow as workflowTable, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, eq, inArray } from 'drizzle-orm'
import { and, asc, eq, inArray, isNull, max } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
import type { ExecutionResult } from '@/executor/types'
@@ -317,3 +320,160 @@ export async function authorizeWorkflowByWorkspacePermission(params: {
workspacePermission,
}
}
// ── Workflow CRUD ──
export interface CreateWorkflowInput {
userId: string
workspaceId: string
name: string
description?: string | null
color?: string
folderId?: string | null
}
export async function createWorkflowRecord(params: CreateWorkflowInput) {
const { userId, workspaceId, name, description = null, color = '#3972F6', folderId = null } = params
const workflowId = crypto.randomUUID()
const now = new Date()
const folderCondition = folderId ? eq(workflowTable.folderId, folderId) : isNull(workflowTable.folderId)
const [maxResult] = await db
.select({ maxOrder: max(workflowTable.sortOrder) })
.from(workflowTable)
.where(and(eq(workflowTable.workspaceId, workspaceId), folderCondition))
const sortOrder = (maxResult?.maxOrder ?? 0) + 1
await db.insert(workflowTable).values({
id: workflowId,
userId,
workspaceId,
folderId,
sortOrder,
name,
description,
color,
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false,
runCount: 0,
variables: {},
})
const { workflowState } = buildDefaultWorkflowArtifacts()
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState)
if (!saveResult.success) {
throw new Error(saveResult.error || 'Failed to save workflow state')
}
return { workflowId, name, workspaceId, folderId, sortOrder, createdAt: now, updatedAt: now }
}
export async function updateWorkflowRecord(
workflowId: string,
updates: { name?: string; description?: string; color?: string; folderId?: string | null }
) {
const setData: Record<string, unknown> = { updatedAt: new Date() }
if (updates.name !== undefined) setData.name = updates.name
if (updates.description !== undefined) setData.description = updates.description
if (updates.color !== undefined) setData.color = updates.color
if (updates.folderId !== undefined) setData.folderId = updates.folderId
await db.update(workflowTable).set(setData).where(eq(workflowTable.id, workflowId))
}
export async function deleteWorkflowRecord(workflowId: string) {
await db.delete(workflowTable).where(eq(workflowTable.id, workflowId))
}
export async function setWorkflowVariables(workflowId: string, variables: Record<string, unknown>) {
await db
.update(workflowTable)
.set({ variables, updatedAt: new Date() })
.where(eq(workflowTable.id, workflowId))
}
// ── Folder CRUD ──
export interface CreateFolderInput {
userId: string
workspaceId: string
name: string
parentId?: string | null
}
export async function createFolderRecord(params: CreateFolderInput) {
const { userId, workspaceId, name, parentId = null } = params
const [maxResult] = await db
.select({ maxOrder: max(workflowFolder.sortOrder) })
.from(workflowFolder)
.where(
and(
eq(workflowFolder.workspaceId, workspaceId),
parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId)
)
)
const sortOrder = (maxResult?.maxOrder ?? 0) + 1
const folderId = crypto.randomUUID()
await db.insert(workflowFolder).values({
id: folderId,
userId,
workspaceId,
parentId,
name,
sortOrder,
createdAt: new Date(),
updatedAt: new Date(),
})
return { folderId, name, workspaceId, parentId }
}
export async function updateFolderRecord(
folderId: string,
updates: { name?: string; parentId?: string | null }
) {
const setData: Record<string, unknown> = { updatedAt: new Date() }
if (updates.name !== undefined) setData.name = updates.name
if (updates.parentId !== undefined) setData.parentId = updates.parentId
await db.update(workflowFolder).set(setData).where(eq(workflowFolder.id, folderId))
}
export async function deleteFolderRecord(folderId: string): Promise<boolean> {
const [folder] = await db
.select({ parentId: workflowFolder.parentId })
.from(workflowFolder)
.where(eq(workflowFolder.id, folderId))
.limit(1)
if (!folder) return false
await db
.update(workflowTable)
.set({ folderId: folder.parentId, updatedAt: new Date() })
.where(eq(workflowTable.folderId, folderId))
await db
.update(workflowFolder)
.set({ parentId: folder.parentId, updatedAt: new Date() })
.where(eq(workflowFolder.parentId, folderId))
await db.delete(workflowFolder).where(eq(workflowFolder.id, folderId))
return true
}
export async function listFolders(workspaceId: string) {
return db
.select({
folderId: workflowFolder.id,
folderName: workflowFolder.name,
parentId: workflowFolder.parentId,
sortOrder: workflowFolder.sortOrder,
})
.from(workflowFolder)
.where(eq(workflowFolder.workspaceId, workspaceId))
.orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt))
}

View File

@@ -1,6 +1,6 @@
import { db } from '@sim/db'
import { workspace as workspaceTable } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { permissions, workspace as workspaceTable } from '@sim/db/schema'
import { and, desc, eq } from 'drizzle-orm'
interface WorkspaceBillingSettings {
billedAccountUserId: string | null
@@ -37,3 +37,23 @@ export async function getWorkspaceBilledAccountUserId(workspaceId: string): Prom
const settings = await getWorkspaceBillingSettings(workspaceId)
return settings?.billedAccountUserId ?? null
}
export async function listUserWorkspaces(userId: string) {
const workspaces = await db
.select({
workspaceId: workspaceTable.id,
workspaceName: workspaceTable.name,
ownerId: workspaceTable.ownerId,
permissionType: permissions.permissionType,
})
.from(permissions)
.innerJoin(workspaceTable, eq(permissions.entityId, workspaceTable.id))
.where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace')))
.orderBy(desc(workspaceTable.createdAt))
return workspaces.map((row) => ({
workspaceId: row.workspaceId,
workspaceName: row.workspaceName,
role: row.ownerId === userId ? 'owner' : row.permissionType,
}))
}