diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 9e6005b25d..fac458b931 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -70,6 +70,7 @@ export interface ToolCatalogEntry { | 'rename_workflow' | 'research' | 'respond' + | 'restore_resource' | 'revert_to_version' | 'run' | 'run_block' @@ -158,6 +159,7 @@ export interface ToolCatalogEntry { | 'rename_workflow' | 'research' | 'respond' + | 'restore_resource' | 'revert_to_version' | 'run' | 'run_block' @@ -479,9 +481,13 @@ export const DeleteFile: ToolCatalogEntry = { parameters: { type: 'object', properties: { - fileId: { type: 'string', description: 'Canonical workspace file ID of the file to delete.' }, + fileIds: { + type: 'array', + description: 'Canonical workspace file IDs of the files to delete.', + items: { type: 'string' }, + }, }, - required: ['fileId'], + required: ['fileIds'], }, resultSchema: { type: 'object', @@ -501,8 +507,14 @@ export const DeleteFolder: ToolCatalogEntry = { mode: 'async', parameters: { type: 'object', - properties: { folderId: { type: 'string', description: 'The folder ID to delete.' } }, - required: ['folderId'], + properties: { + folderIds: { + type: 'array', + description: 'The folder IDs to delete.', + items: { type: 'string' }, + }, + }, + required: ['folderIds'], }, requiresConfirmation: true, requiredPermission: 'write', @@ -515,8 +527,14 @@ export const DeleteWorkflow: ToolCatalogEntry = { mode: 'async', parameters: { type: 'object', - properties: { workflowId: { type: 'string', description: 'The workflow ID to delete.' } }, - required: ['workflowId'], + properties: { + workflowIds: { + type: 'array', + description: 'The workflow IDs to delete.', + items: { type: 'string' }, + }, + }, + required: ['workflowIds'], }, requiresConfirmation: true, requiredPermission: 'write', @@ -1176,7 +1194,7 @@ export const Glob: ToolCatalogEntry = { 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "workflow configs" or "knowledge bases", not a full sentence like "Finding workflow configs".', }, }, - required: ['pattern'], + required: ['pattern', 'toolTitle'], }, } @@ -1221,7 +1239,7 @@ export const Grep: ToolCatalogEntry = { 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "Slack integrations" or "deployed workflows", not a full sentence like "Searching for Slack integrations".', }, }, - required: ['pattern'], + required: ['pattern', 'toolTitle'], }, } @@ -1322,22 +1340,21 @@ export const KnowledgeBase: ToolCatalogEntry = { description: 'Tag definition IDs to opt out of (optional for add_connector). See tagDefinitions in the connector schema.', }, - documentId: { - type: 'string', - description: 'Document ID (required for delete_document, update_document)', + documentId: { type: 'string', description: 'Document ID (required for update_document)' }, + documentIds: { + type: 'array', + description: 'Document IDs (for batch delete_document)', + items: { type: 'string' }, }, enabled: { type: 'boolean', description: 'Enable/disable a document (optional for update_document)', }, - fileId: { - type: 'string', + fileIds: { + type: 'array', description: - 'Canonical workspace file ID to add as a document (preferred for add_file). Discover via read("files/{name}/meta.json") or glob("files/by-id/*/meta.json").', - }, - filePath: { - type: 'string', - description: 'Legacy workspace file reference for add_file. Prefer fileId.', + 'Canonical workspace file IDs to add as documents (for add_file). Discover via read("files/{name}/meta.json") or glob("files/by-id/*/meta.json").', + items: { type: 'string' }, }, filename: { type: 'string', @@ -1348,6 +1365,11 @@ export const KnowledgeBase: ToolCatalogEntry = { description: 'Knowledge base ID (required for get, query, add_file, list_tags, create_tag, get_tag_usage)', }, + knowledgeBaseIds: { + type: 'array', + description: 'Knowledge base IDs (for batch delete)', + items: { type: 'string' }, + }, name: { type: 'string', description: "Name of the knowledge base (required for 'create')", @@ -1470,9 +1492,11 @@ export const ManageCredential: ToolCatalogEntry = { parameters: { type: 'object', properties: { - credentialId: { - type: 'string', - description: 'The credential ID (from environment/credentials.json)', + credentialId: { type: 'string', description: 'The credential ID (required for rename)' }, + credentialIds: { + type: 'array', + description: 'Array of credential IDs (for batch delete)', + items: { type: 'string' }, }, displayName: { type: 'string', description: 'New display name (required for rename)' }, operation: { @@ -1481,7 +1505,7 @@ export const ManageCredential: ToolCatalogEntry = { enum: ['rename', 'delete'], }, }, - required: ['operation', 'credentialId'], + required: ['operation'], }, requiresConfirmation: true, requiredPermission: 'admin', @@ -1542,7 +1566,12 @@ export const ManageCustomTool: ToolCatalogEntry = { toolId: { type: 'string', description: - "The ID of the custom tool (required for edit/delete). Must be the exact toolId from the get_workflow_data custom tool response - do not guess or construct it. DO NOT PROVIDE THE TOOL ID IF THE OPERATION IS 'ADD'.", + "The ID of the custom tool (required for edit). Must be the exact toolId from the get_workflow_data custom tool response - do not guess or construct it. DO NOT PROVIDE THE TOOL ID IF THE OPERATION IS 'ADD'.", + }, + toolIds: { + type: 'array', + description: 'Array of custom tool IDs (for batch delete)', + items: { type: 'string' }, }, }, required: ['operation'], @@ -1564,7 +1593,12 @@ export const ManageJob: ToolCatalogEntry = { 'Operation-specific arguments. For create: {title, prompt, cron?, time?, timezone?, lifecycle?, successCondition?, maxRuns?}. For get/delete: {jobId}. For update: {jobId, title?, prompt?, cron?, timezone?, status?, lifecycle?, successCondition?, maxRuns?}. For list: no args needed.', properties: { cron: { type: 'string', description: 'Cron expression for recurring jobs' }, - jobId: { type: 'string', description: 'Job ID (required for get, update, delete)' }, + jobId: { type: 'string', description: 'Job ID (required for get, update)' }, + jobIds: { + type: 'array', + description: 'Array of job IDs (for batch delete)', + items: { type: 'string' }, + }, lifecycle: { type: 'string', description: @@ -1702,9 +1736,11 @@ export const MaterializeFile: ToolCatalogEntry = { parameters: { type: 'object', properties: { - fileName: { - type: 'string', - description: 'The name of the uploaded file to materialize (e.g. "report.pdf")', + fileNames: { + type: 'array', + description: + 'The names of the uploaded files to materialize (e.g. ["report.pdf", "data.csv"])', + items: { type: 'string' }, }, knowledgeBaseId: { type: 'string', @@ -1724,7 +1760,7 @@ export const MaterializeFile: ToolCatalogEntry = { 'Custom name for the table (only used with operation "table"). Defaults to the file name without extension.', }, }, - required: ['fileName'], + required: ['fileNames'], }, requiredPermission: 'write', } @@ -1761,9 +1797,13 @@ export const MoveWorkflow: ToolCatalogEntry = { type: 'string', description: 'Target folder ID. Omit or pass empty string to move to workspace root.', }, - workflowId: { type: 'string', description: 'The workflow ID to move.' }, + workflowIds: { + type: 'array', + description: 'The workflow IDs to move.', + items: { type: 'string' }, + }, }, - required: ['workflowId'], + required: ['workflowIds'], }, requiredPermission: 'write', } @@ -1813,14 +1853,24 @@ export const OpenResource: ToolCatalogEntry = { parameters: { type: 'object', properties: { - id: { type: 'string', description: 'The resource ID to open.' }, - type: { - type: 'string', - description: 'The resource type to open.', - enum: ['workflow', 'table', 'knowledgebase', 'file'], + resources: { + type: 'array', + description: 'Array of resources to open. Each item must have type and id.', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'The resource ID.' }, + type: { + type: 'string', + description: 'The resource type.', + enum: ['workflow', 'table', 'knowledgebase', 'file'], + }, + }, + required: ['type', 'id'], + }, }, }, - required: ['type', 'id'], + required: ['resources'], }, } @@ -1948,6 +1998,27 @@ export const Respond: ToolCatalogEntry = { hidden: true, } +export const RestoreResource: ToolCatalogEntry = { + id: 'restore_resource', + name: 'restore_resource', + executor: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + id: { type: 'string', description: 'The canonical resource ID to restore.' }, + type: { + type: 'string', + description: 'The resource type to restore.', + enum: ['workflow', 'table', 'file', 'knowledgebase', 'folder'], + }, + }, + required: ['type', 'id'], + }, + requiresConfirmation: true, + requiredPermission: 'admin', +} + export const RevertToVersion: ToolCatalogEntry = { id: 'revert_to_version', name: 'revert_to_version', @@ -2208,7 +2279,7 @@ export const SearchOnline: ToolCatalogEntry = { 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "pricing changes" or "Slack webhook docs", not a full sentence like "Searching online for pricing changes".', }, }, - required: ['query'], + required: ['query', 'toolTitle'], }, } @@ -2534,7 +2605,13 @@ export const UserTable: ToolCatalogEntry = { }, tableId: { type: 'string', - description: "Table ID (required for most operations except 'create')", + description: + "Table ID (required for most operations except 'create' and batch 'delete')", + }, + tableIds: { + type: 'array', + description: 'Array of table IDs (for batch delete)', + items: { type: 'string' }, }, unique: { type: 'boolean', @@ -3030,6 +3107,7 @@ export const TOOL_CATALOG: Record = { [RenameWorkflow.id]: RenameWorkflow, [Research.id]: Research, [Respond.id]: Respond, + [RestoreResource.id]: RestoreResource, [RevertToVersion.id]: RevertToVersion, [Run.id]: Run, [RunBlock.id]: RunBlock, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 4f0f96f87f..19b4e260b1 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -289,12 +289,15 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - fileId: { - type: 'string', - description: 'Canonical workspace file ID of the file to delete.', + fileIds: { + type: 'array', + description: 'Canonical workspace file IDs of the files to delete.', + items: { + type: 'string', + }, }, }, - required: ['fileId'], + required: ['fileIds'], }, resultSchema: { type: 'object', @@ -315,12 +318,15 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - folderId: { - type: 'string', - description: 'The folder ID to delete.', + folderIds: { + type: 'array', + description: 'The folder IDs to delete.', + items: { + type: 'string', + }, }, }, - required: ['folderId'], + required: ['folderIds'], }, resultSchema: undefined, }, @@ -328,12 +334,15 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - workflowId: { - type: 'string', - description: 'The workflow ID to delete.', + workflowIds: { + type: 'array', + description: 'The workflow IDs to delete.', + items: { + type: 'string', + }, }, }, - required: ['workflowId'], + required: ['workflowIds'], }, resultSchema: undefined, }, @@ -966,7 +975,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "workflow configs" or "knowledge bases", not a full sentence like "Finding workflow configs".', }, }, - required: ['pattern'], + required: ['pattern', 'toolTitle'], }, resultSchema: undefined, }, @@ -1013,7 +1022,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "Slack integrations" or "deployed workflows", not a full sentence like "Searching for Slack integrations".', }, }, - required: ['pattern'], + required: ['pattern', 'toolTitle'], }, resultSchema: undefined, }, @@ -1108,20 +1117,26 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, documentId: { type: 'string', - description: 'Document ID (required for delete_document, update_document)', + description: 'Document ID (required for update_document)', + }, + documentIds: { + type: 'array', + description: 'Document IDs (for batch delete_document)', + items: { + type: 'string', + }, }, enabled: { type: 'boolean', description: 'Enable/disable a document (optional for update_document)', }, - fileId: { - type: 'string', + fileIds: { + type: 'array', description: - 'Canonical workspace file ID to add as a document (preferred for add_file). Discover via read("files/{name}/meta.json") or glob("files/by-id/*/meta.json").', - }, - filePath: { - type: 'string', - description: 'Legacy workspace file reference for add_file. Prefer fileId.', + 'Canonical workspace file IDs to add as documents (for add_file). Discover via read("files/{name}/meta.json") or glob("files/by-id/*/meta.json").', + items: { + type: 'string', + }, }, filename: { type: 'string', @@ -1132,6 +1147,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Knowledge base ID (required for get, query, add_file, list_tags, create_tag, get_tag_usage)', }, + knowledgeBaseIds: { + type: 'array', + description: 'Knowledge base IDs (for batch delete)', + items: { + type: 'string', + }, + }, name: { type: 'string', description: "Name of the knowledge base (required for 'create')", @@ -1259,7 +1281,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { credentialId: { type: 'string', - description: 'The credential ID (from environment/credentials.json)', + description: 'The credential ID (required for rename)', + }, + credentialIds: { + type: 'array', + description: 'Array of credential IDs (for batch delete)', + items: { + type: 'string', + }, }, displayName: { type: 'string', @@ -1271,7 +1300,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { enum: ['rename', 'delete'], }, }, - required: ['operation', 'credentialId'], + required: ['operation'], }, resultSchema: undefined, }, @@ -1340,7 +1369,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { toolId: { type: 'string', description: - "The ID of the custom tool (required for edit/delete). Must be the exact toolId from the get_workflow_data custom tool response - do not guess or construct it. DO NOT PROVIDE THE TOOL ID IF THE OPERATION IS 'ADD'.", + "The ID of the custom tool (required for edit). Must be the exact toolId from the get_workflow_data custom tool response - do not guess or construct it. DO NOT PROVIDE THE TOOL ID IF THE OPERATION IS 'ADD'.", + }, + toolIds: { + type: 'array', + description: 'Array of custom tool IDs (for batch delete)', + items: { + type: 'string', + }, }, }, required: ['operation'], @@ -1362,7 +1398,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, jobId: { type: 'string', - description: 'Job ID (required for get, update, delete)', + description: 'Job ID (required for get, update)', + }, + jobIds: { + type: 'array', + description: 'Array of job IDs (for batch delete)', + items: { + type: 'string', + }, }, lifecycle: { type: 'string', @@ -1497,9 +1540,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - fileName: { - type: 'string', - description: 'The name of the uploaded file to materialize (e.g. "report.pdf")', + fileNames: { + type: 'array', + description: + 'The names of the uploaded files to materialize (e.g. ["report.pdf", "data.csv"])', + items: { + type: 'string', + }, }, knowledgeBaseId: { type: 'string', @@ -1519,7 +1566,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'Custom name for the table (only used with operation "table"). Defaults to the file name without extension.', }, }, - required: ['fileName'], + required: ['fileNames'], }, resultSchema: undefined, }, @@ -1549,12 +1596,15 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', description: 'Target folder ID. Omit or pass empty string to move to workspace root.', }, - workflowId: { - type: 'string', - description: 'The workflow ID to move.', + workflowIds: { + type: 'array', + description: 'The workflow IDs to move.', + items: { + type: 'string', + }, }, }, - required: ['workflowId'], + required: ['workflowIds'], }, resultSchema: undefined, }, @@ -1590,17 +1640,27 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - id: { - type: 'string', - description: 'The resource ID to open.', - }, - type: { - type: 'string', - description: 'The resource type to open.', - enum: ['workflow', 'table', 'knowledgebase', 'file'], + resources: { + type: 'array', + description: 'Array of resources to open. Each item must have type and id.', + items: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The resource ID.', + }, + type: { + type: 'string', + description: 'The resource type.', + enum: ['workflow', 'table', 'knowledgebase', 'file'], + }, + }, + required: ['type', 'id'], + }, }, }, - required: ['type', 'id'], + required: ['resources'], }, resultSchema: undefined, }, @@ -1730,6 +1790,24 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + restore_resource: { + parameters: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The canonical resource ID to restore.', + }, + type: { + type: 'string', + description: 'The resource type to restore.', + enum: ['workflow', 'table', 'file', 'knowledgebase', 'folder'], + }, + }, + required: ['type', 'id'], + }, + resultSchema: undefined, + }, revert_to_version: { parameters: { type: 'object', @@ -1974,7 +2052,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "pricing changes" or "Slack webhook docs", not a full sentence like "Searching online for pricing changes".', }, }, - required: ['query'], + required: ['query', 'toolTitle'], }, resultSchema: undefined, }, @@ -2305,7 +2383,15 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, tableId: { type: 'string', - description: "Table ID (required for most operations except 'create')", + description: + "Table ID (required for most operations except 'create' and batch 'delete')", + }, + tableIds: { + type: 'array', + description: 'Array of table IDs (for batch delete)', + items: { + type: 'string', + }, }, unique: { type: 'boolean', diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index 4ee4debeb9..f354b8fe2d 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -39,6 +39,7 @@ import { Read as ReadTool, Redeploy, RenameWorkflow, + RestoreResource, RevertToVersion, RunBlock, RunFromBlock, @@ -80,6 +81,7 @@ import { executeMaterializeFile } from '../tools/handlers/materialize-file' import { executeOAuthGetAuthLink, executeOAuthRequestAccess } from '../tools/handlers/oauth' import { executeGetPlatformActions } from '../tools/handlers/platform' import { executeOpenResource } from '../tools/handlers/resources' +import { executeRestoreResource } from '../tools/handlers/restore-resource' import { executeVfsGlob, executeVfsGrep, executeVfsRead } from '../tools/handlers/vfs' import { executeCreateFolder, @@ -176,6 +178,7 @@ function buildHandlerMap(): Record { [OauthGetAuthLink.id]: h(executeOAuthGetAuthLink), [OauthRequestAccess.id]: h(executeOAuthRequestAccess), [OpenResource.id]: h(executeOpenResource), + [RestoreResource.id]: h(executeRestoreResource), [GetPlatformActions.id]: h(executeGetPlatformActions), [MaterializeFile.id]: h(executeMaterializeFile), [FunctionExecute.id]: h(executeFunctionExecute), diff --git a/apps/sim/lib/copilot/tools/handlers/jobs.ts b/apps/sim/lib/copilot/tools/handlers/jobs.ts index fa1d9107cb..f271e19dde 100644 --- a/apps/sim/lib/copilot/tools/handlers/jobs.ts +++ b/apps/sim/lib/copilot/tools/handlers/jobs.ts @@ -184,6 +184,7 @@ interface ManageJobParams { operation: 'create' | 'list' | 'get' | 'update' | 'delete' args?: { jobId?: string + jobIds?: string[] title?: string prompt?: string cron?: string @@ -423,43 +424,46 @@ export async function executeManageJob( } case 'delete': { - if (!args?.jobId) { - return { success: false, error: 'jobId is required for delete operation' } + const jobIds = args?.jobIds ?? (args?.jobId ? [args.jobId] : []) + if (jobIds.length === 0) { + return { success: false, error: 'jobId or jobIds is required for delete operation' } } try { - const [existing] = await db - .select({ id: workflowSchedule.id }) - .from(workflowSchedule) - .where( - and(eq(workflowSchedule.id, args.jobId), ACTIVE_JOB_CONDITION(context.workspaceId)) - ) - .limit(1) + const deleted: string[] = [] + const notFound: string[] = [] - if (!existing) { - return { success: false, error: `Job not found: ${args.jobId}` } + for (const jobId of jobIds) { + const [existing] = await db + .select({ id: workflowSchedule.id }) + .from(workflowSchedule) + .where(and(eq(workflowSchedule.id, jobId), ACTIVE_JOB_CONDITION(context.workspaceId))) + .limit(1) + + if (!existing) { + notFound.push(jobId) + continue + } + + await db.delete(workflowSchedule).where(eq(workflowSchedule.id, jobId)) + deleted.push(jobId) + + logger.info('Job deleted', { jobId }) + + recordAudit({ + workspaceId: context.workspaceId || null, + actorId: context.userId, + action: AuditAction.SCHEDULE_UPDATED, + resourceType: AuditResourceType.SCHEDULE, + resourceId: jobId, + description: `Deleted job`, + metadata: { operation: 'delete' }, + }) } - await db.delete(workflowSchedule).where(eq(workflowSchedule.id, args.jobId)) - - logger.info('Job deleted', { jobId: args.jobId }) - - recordAudit({ - workspaceId: context.workspaceId || null, - actorId: context.userId, - action: AuditAction.SCHEDULE_UPDATED, - resourceType: AuditResourceType.SCHEDULE, - resourceId: args.jobId, - description: `Deleted job`, - metadata: { operation: 'delete' }, - }) - return { - success: true, - output: { - jobId: args.jobId, - message: 'Job deleted successfully', - }, + success: deleted.length > 0, + output: { deleted, notFound }, } } catch (err) { logger.error('Failed to delete job', { diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts index ce6c234147..da775526d4 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts @@ -7,30 +7,69 @@ export function executeManageCredential( rawParams: Record, _context: ExecutionContext ): Promise { - const params = rawParams as { operation: string; credentialId: string; displayName?: string } - const { operation, credentialId, displayName } = params - if (!credentialId) return Promise.resolve({ success: false, error: 'credentialId is required' }) + const params = rawParams as { + operation: string + credentialId?: string + credentialIds?: string[] + displayName?: string + } + const { operation, displayName } = params return (async () => { try { - const [row] = await db - .select({ id: credential.id, type: credential.type, displayName: credential.displayName }) - .from(credential) - .where(eq(credential.id, credentialId)) - .limit(1) - if (!row) return { success: false, error: 'Credential not found' } - if (row.type !== 'oauth') - return { success: false, error: 'Only OAuth credentials can be managed with this tool.' } switch (operation) { - case 'rename': + case 'rename': { + const credentialId = params.credentialId + if (!credentialId) return { success: false, error: 'credentialId is required for rename' } if (!displayName) return { success: false, error: 'displayName is required for rename' } + const [row] = await db + .select({ + id: credential.id, + type: credential.type, + displayName: credential.displayName, + }) + .from(credential) + .where(eq(credential.id, credentialId)) + .limit(1) + if (!row) return { success: false, error: 'Credential not found' } + if (row.type !== 'oauth') + return { + success: false, + error: 'Only OAuth credentials can be managed with this tool.', + } await db .update(credential) .set({ displayName, updatedAt: new Date() }) .where(eq(credential.id, credentialId)) return { success: true, output: { credentialId, displayName } } - case 'delete': - await db.delete(credential).where(eq(credential.id, credentialId)) - return { success: true, output: { credentialId, deleted: true } } + } + case 'delete': { + const ids: string[] = + params.credentialIds ?? (params.credentialId ? [params.credentialId] : []) + if (ids.length === 0) + return { success: false, error: 'credentialId or credentialIds is required for delete' } + + const deleted: string[] = [] + const failed: string[] = [] + + for (const id of ids) { + const [row] = await db + .select({ id: credential.id, type: credential.type }) + .from(credential) + .where(eq(credential.id, id)) + .limit(1) + if (!row || row.type !== 'oauth') { + failed.push(id) + continue + } + await db.delete(credential).where(eq(credential.id, id)) + deleted.push(id) + } + + return { + success: deleted.length > 0, + output: { deleted, failed }, + } + } default: return { success: false, diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts index 57b0efd433..b67eb3cf14 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts @@ -23,6 +23,7 @@ interface ManageCustomToolSchema { interface ManageCustomToolParams { operation?: string toolId?: string + toolIds?: string[] schema?: ManageCustomToolSchema code?: string title?: string @@ -159,26 +160,35 @@ export async function executeManageCustomTool( } if (operation === 'delete') { - if (!params.toolId) { - return { success: false, error: "'toolId' is required for operation 'delete'" } + const toolIds: string[] = params.toolIds ?? (params.toolId ? [params.toolId] : []) + if (toolIds.length === 0) { + return { success: false, error: "'toolId' or 'toolIds' is required for operation 'delete'" } } - const deleted = await deleteCustomTool({ - toolId: params.toolId, - userId: context.userId, - workspaceId, - }) - if (!deleted) { - return { success: false, error: `Custom tool not found: ${params.toolId}` } + const deleted: string[] = [] + const notFound: string[] = [] + + for (const toolId of toolIds) { + const result = await deleteCustomTool({ + toolId, + userId: context.userId, + workspaceId, + }) + if (result) { + deleted.push(toolId) + } else { + notFound.push(toolId) + } } return { - success: true, + success: deleted.length > 0, output: { - success: true, + success: deleted.length > 0, operation, - toolId: params.toolId, - message: 'Deleted custom tool', + deleted, + notFound, + message: `Deleted ${deleted.length} custom tool(s)`, }, } } diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index fbcf357f8d..0f59a81a6f 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -186,9 +186,12 @@ export async function executeMaterializeFile( params: Record, context: ExecutionContext ): Promise { - const fileName = params.fileName as string | undefined - if (!fileName) { - return { success: false, error: "Missing required parameter 'fileName'" } + const fileNames: string[] = + (params.fileNames as string[] | undefined) ?? + ([params.fileName as string | undefined].filter(Boolean) as string[]) + + if (fileNames.length === 0) { + return { success: false, error: "Missing required parameter 'fileNames'" } } if (!context.chatId) { @@ -200,22 +203,37 @@ export async function executeMaterializeFile( } const operation = (params.operation as string | undefined) || 'save' + const succeeded: string[] = [] + const failed: Array<{ fileName: string; error: string }> = [] - try { - if (operation === 'import') { - return await executeImport(fileName, context.chatId, context.workspaceId, context.userId) - } - return await executeSave(fileName, context.chatId) - } catch (err) { - logger.error('materialize_file failed', { - fileName, - operation, - chatId: context.chatId, - error: err instanceof Error ? err.message : String(err), - }) - return { - success: false, - error: err instanceof Error ? err.message : 'Failed to materialize file', + for (const fileName of fileNames) { + try { + if (operation === 'import') { + await executeImport(fileName, context.chatId, context.workspaceId, context.userId) + } else { + await executeSave(fileName, context.chatId) + } + succeeded.push(fileName) + } catch (err) { + logger.error('materialize_file failed', { + fileName, + operation, + chatId: context.chatId, + error: err instanceof Error ? err.message : String(err), + }) + failed.push({ + fileName, + error: err instanceof Error ? err.message : 'Failed to materialize file', + }) } } + + return { + success: succeeded.length > 0, + output: { succeeded, failed }, + error: + failed.length > 0 + ? `Failed to materialize: ${failed.map((f) => f.fileName).join(', ')}` + : undefined, + } } diff --git a/apps/sim/lib/copilot/tools/handlers/param-types.ts b/apps/sim/lib/copilot/tools/handlers/param-types.ts index ccef118090..4e56d5c4ce 100644 --- a/apps/sim/lib/copilot/tools/handlers/param-types.ts +++ b/apps/sim/lib/copilot/tools/handlers/param-types.ts @@ -172,11 +172,11 @@ export interface UpdateWorkflowParams { } export interface DeleteWorkflowParams { - workflowId: string + workflowIds: string[] } export interface MoveWorkflowParams { - workflowId: string + workflowIds: string[] folderId: string | null } @@ -191,7 +191,7 @@ export interface RenameFolderParams { } export interface DeleteFolderParams { - folderId: string + folderIds: string[] } export interface UpdateWorkspaceMcpServerParams { @@ -207,7 +207,13 @@ export interface DeleteWorkspaceMcpServerParams { export type OpenResourceType = MothershipResourceType +export interface OpenResourceItem { + type?: OpenResourceType + id?: string +} + export interface OpenResourceParams { + resources?: OpenResourceItem[] type?: OpenResourceType id?: string } diff --git a/apps/sim/lib/copilot/tools/handlers/resources.ts b/apps/sim/lib/copilot/tools/handlers/resources.ts index 43a6838609..ea8d92cf81 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.ts @@ -5,91 +5,104 @@ import { getTableById } from '@/lib/table/service' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getWorkflowById } from '@/lib/workflows/utils' import { isUuid } from '@/executor/constants' -import type { OpenResourceParams, ValidOpenResourceParams } from './param-types' +import type { OpenResourceItem, OpenResourceParams, ValidOpenResourceParams } from './param-types' const VALID_OPEN_RESOURCE_TYPES = new Set(Object.values(MothershipResourceType)) +async function resolveResource( + item: ValidOpenResourceParams, + context: ExecutionContext +): Promise<{ type: string; id: string; title: string } | { error: string }> { + const resourceType = item.type + let resourceId = item.id + let title: string = resourceType + + if (resourceType === 'file') { + if (!context.workspaceId) + return { error: 'Opening a workspace file requires workspace context.' } + if (!isUuid(item.id)) + return { error: 'open_resource for files requires the canonical file UUID.' } + const record = await getWorkspaceFile(context.workspaceId, item.id) + if (!record) return { error: `No workspace file with id "${item.id}".` } + resourceId = record.id + title = record.name + } + if (resourceType === 'workflow') { + const wf = await getWorkflowById(item.id) + if (!wf) return { error: `No workflow with id "${item.id}".` } + if (context.workspaceId && wf.workspaceId !== context.workspaceId) + return { error: `Workflow not found in the current workspace.` } + resourceId = wf.id + title = wf.name + } + if (resourceType === 'table') { + const tbl = await getTableById(item.id) + if (!tbl) return { error: `No table with id "${item.id}".` } + if (context.workspaceId && tbl.workspaceId !== context.workspaceId) + return { error: `Table not found in the current workspace.` } + resourceId = tbl.id + title = tbl.name + } + if (resourceType === 'knowledgebase') { + const kb = await getKnowledgeBaseById(item.id) + if (!kb) return { error: `No knowledge base with id "${item.id}".` } + if (context.workspaceId && kb.workspaceId !== context.workspaceId) + return { error: `Knowledge base not found in the current workspace.` } + resourceId = kb.id + title = kb.name + } + + return { type: resourceType, id: resourceId, title } +} + export async function executeOpenResource( rawParams: Record, context: ExecutionContext ): Promise { const params = rawParams as OpenResourceParams - const validated = validateOpenResourceParams(params) - if (!validated.success) return { success: false, error: validated.error } - const resourceType = validated.params.type - let resourceId = validated.params.id - let title: string = resourceType + const items: OpenResourceItem[] = + params.resources ?? (params.type && params.id ? [{ type: params.type, id: params.id }] : []) - if (resourceType === 'file') { - if (!context.workspaceId) - return { success: false, error: 'Opening a workspace file requires workspace context.' } - if (!isUuid(validated.params.id)) - return { success: false, error: 'open_resource for files requires the canonical file UUID.' } - const record = await getWorkspaceFile(context.workspaceId, validated.params.id) - if (!record) - return { success: false, error: `No workspace file with id "${validated.params.id}".` } - resourceId = record.id - title = record.name + if (items.length === 0) { + return { success: false, error: 'resources array is required' } } - if (resourceType === 'workflow') { - const wf = await getWorkflowById(validated.params.id) - if (!wf) return { success: false, error: `No workflow with id "${validated.params.id}".` } - if (context.workspaceId && wf.workspaceId !== context.workspaceId) - return { success: false, error: `Workflow not found in the current workspace.` } - resourceId = wf.id - title = wf.name - } - if (resourceType === 'table') { - const tbl = await getTableById(validated.params.id) - if (!tbl) return { success: false, error: `No table with id "${validated.params.id}".` } - if (context.workspaceId && tbl.workspaceId !== context.workspaceId) - return { success: false, error: `Table not found in the current workspace.` } - resourceId = tbl.id - title = tbl.name - } - if (resourceType === 'knowledgebase') { - const kb = await getKnowledgeBaseById(validated.params.id) - if (!kb) return { success: false, error: `No knowledge base with id "${validated.params.id}".` } - if (context.workspaceId && kb.workspaceId !== context.workspaceId) - return { success: false, error: `Knowledge base not found in the current workspace.` } - resourceId = kb.id - title = kb.name + + const resources: Array<{ type: string; id: string; title: string }> = [] + const errors: string[] = [] + + for (const item of items) { + const validated = validateOpenResourceItem(item) + if (!validated.success) { + errors.push(validated.error) + continue + } + const result = await resolveResource(validated.params, context) + if ('error' in result) { + errors.push(result.error) + } else { + resources.push(result) + } } return { - success: true, - output: { message: `Opened ${resourceType} ${resourceId} for the user` }, - resources: [ - { - type: resourceType, - id: resourceId, - title, - }, - ], + success: resources.length > 0, + output: { opened: resources.length, errors }, + resources, } } -function validateOpenResourceParams( - params: OpenResourceParams +function validateOpenResourceItem( + item: OpenResourceItem ): { success: true; params: ValidOpenResourceParams } | { success: false; error: string } { - if (!params.type) { + if (!item.type) { return { success: false, error: 'type is required' } } - - if (!VALID_OPEN_RESOURCE_TYPES.has(params.type)) { - return { success: false, error: `Invalid resource type: ${params.type}` } + if (!VALID_OPEN_RESOURCE_TYPES.has(item.type)) { + return { success: false, error: `Invalid resource type: ${item.type}` } } - - if (!params.id) { - return { success: false, error: `${params.type} resources require \`id\`` } - } - - return { - success: true, - params: { - type: params.type, - id: params.id, - }, + if (!item.id) { + return { success: false, error: `${item.type} resources require \`id\`` } } + return { success: true, params: { type: item.type, id: item.id } } } diff --git a/apps/sim/lib/copilot/tools/handlers/restore-resource.ts b/apps/sim/lib/copilot/tools/handlers/restore-resource.ts new file mode 100644 index 0000000000..3954d08fbf --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/restore-resource.ts @@ -0,0 +1,106 @@ +import { createLogger } from '@sim/logger' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { generateId } from '@/lib/core/utils/uuid' +import { restoreKnowledgeBase } from '@/lib/knowledge/service' +import { getTableById, restoreTable } from '@/lib/table/service' +import { + getWorkspaceFile, + restoreWorkspaceFile, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { restoreWorkflow } from '@/lib/workflows/lifecycle' +import { performRestoreFolder } from '@/lib/workflows/orchestration/folder-lifecycle' + +const logger = createLogger('RestoreResource') + +const VALID_TYPES = new Set(['workflow', 'table', 'file', 'knowledgebase', 'folder']) + +export async function executeRestoreResource( + rawParams: Record, + context: ExecutionContext +): Promise { + const type = rawParams.type as string | undefined + const id = rawParams.id as string | undefined + + if (!type || !VALID_TYPES.has(type)) { + return { success: false, error: `Invalid type. Must be one of: ${[...VALID_TYPES].join(', ')}` } + } + if (!id) { + return { success: false, error: 'id is required' } + } + if (!context.workspaceId) { + return { success: false, error: 'Workspace context required' } + } + + const requestId = generateId().slice(0, 8) + + try { + switch (type) { + case 'workflow': { + const result = await restoreWorkflow(id, { requestId }) + if (!result.restored) { + return { success: false, error: 'Workflow not found or not archived' } + } + logger.info('Workflow restored via copilot', { workflowId: id }) + return { + success: true, + output: { type, id, name: result.workflow?.name }, + resources: [{ type: 'workflow', id, title: result.workflow?.name || id }], + } + } + + case 'table': { + await restoreTable(id, requestId) + const table = await getTableById(id) + const tableName = table?.name || id + logger.info('Table restored via copilot', { tableId: id, name: tableName }) + return { + success: true, + output: { type, id, name: tableName }, + resources: [{ type: 'table', id, title: tableName }], + } + } + + case 'file': { + await restoreWorkspaceFile(context.workspaceId, id) + const fileRecord = await getWorkspaceFile(context.workspaceId, id) + const fileName = fileRecord?.name || id + logger.info('File restored via copilot', { fileId: id, name: fileName }) + return { + success: true, + output: { type, id, name: fileName }, + resources: [{ type: 'file', id, title: fileName }], + } + } + + case 'knowledgebase': { + await restoreKnowledgeBase(id, requestId) + logger.info('Knowledge base restored via copilot', { knowledgeBaseId: id }) + return { + success: true, + output: { type, id }, + } + } + + case 'folder': { + const result = await performRestoreFolder({ + folderId: id, + workspaceId: context.workspaceId, + userId: context.userId, + }) + if (!result.success) { + return { success: false, error: result.error || 'Failed to restore folder' } + } + logger.info('Folder restored via copilot', { folderId: id }) + return { + success: true, + output: { type, id, restoredItems: result.restoredItems }, + } + } + + default: + return { success: false, error: `Unsupported type: ${type}` } + } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } +} diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index 8e2d82d955..ed3ee1c3b4 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -412,17 +412,27 @@ export async function executeMoveWorkflow( context: ExecutionContext ): Promise { try { - const workflowId = params.workflowId - if (!workflowId) { - return { success: false, error: 'workflowId is required' } + const workflowIds = params.workflowIds + if (!workflowIds || workflowIds.length === 0) { + return { success: false, error: 'workflowIds is required' } } - await ensureWorkflowAccess(workflowId, context.userId, 'write') const folderId = params.folderId || null - assertWorkflowMutationNotAborted(context) - await updateWorkflowRecord(workflowId, { folderId }) + const moved: string[] = [] + const failed: string[] = [] - return { success: true, output: { workflowId, folderId } } + for (const workflowId of workflowIds) { + try { + await ensureWorkflowAccess(workflowId, context.userId, 'write') + assertWorkflowMutationNotAborted(context) + await updateWorkflowRecord(workflowId, { folderId }) + moved.push(workflowId) + } catch { + failed.push(workflowId) + } + } + + return { success: moved.length > 0, output: { moved, failed, folderId } } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) } } @@ -661,26 +671,37 @@ export async function executeDeleteWorkflow( context: ExecutionContext ): Promise { try { - const workflowId = params.workflowId - if (!workflowId) { - return { success: false, error: 'workflowId is required' } + const workflowIds = params.workflowIds + if (!workflowIds || workflowIds.length === 0) { + return { success: false, error: 'workflowIds is required' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'admin' - ) - assertWorkflowMutationNotAborted(context) + const deleted: Array<{ workflowId: string; name: string }> = [] + const failed: string[] = [] - const result = await performDeleteWorkflow({ workflowId, userId: context.userId }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to delete workflow' } + for (const workflowId of workflowIds) { + try { + const { workflow: workflowRecord } = await ensureWorkflowAccess( + workflowId, + context.userId, + 'admin' + ) + assertWorkflowMutationNotAborted(context) + + const result = await performDeleteWorkflow({ workflowId, userId: context.userId }) + if (result.success) { + deleted.push({ workflowId, name: workflowRecord.name }) + } else { + failed.push(workflowId) + } + } catch { + failed.push(workflowId) + } } return { - success: true, - output: { workflowId, name: workflowRecord.name, deleted: true }, + success: deleted.length > 0, + output: { deleted, failed }, } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) } @@ -692,34 +713,42 @@ export async function executeDeleteFolder( context: ExecutionContext ): Promise { try { - const folderId = params.folderId - if (!folderId) { - return { success: false, error: 'folderId is required' } + const folderIds = params.folderIds + if (!folderIds || folderIds.length === 0) { + return { success: false, error: 'folderIds is required' } } const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) await ensureWorkspaceAccess(workspaceId, context.userId, 'admin') const folders = await listFolders(workspaceId) - const folder = folders.find((f) => f.folderId === folderId) - if (!folder) { - return { success: false, error: 'Folder not found' } + const deleted: string[] = [] + const failed: string[] = [] + + for (const folderId of folderIds) { + const folder = folders.find((f) => f.folderId === folderId) + if (!folder) { + failed.push(folderId) + continue + } + + assertWorkflowMutationNotAborted(context) + + const result = await performDeleteFolder({ + folderId, + workspaceId, + userId: context.userId, + folderName: folder.folderName, + }) + + if (result.success) { + deleted.push(folderId) + } else { + failed.push(folderId) + } } - assertWorkflowMutationNotAborted(context) - - const result = await performDeleteFolder({ - folderId, - workspaceId, - userId: context.userId, - folderName: folder.folderName, - }) - - if (!result.success) { - return { success: false, error: result.error || 'Failed to delete folder' } - } - - return { success: true, output: { folderId, deleted: true, ...result.deletedItems } } + return { success: deleted.length > 0, output: { deleted, failed } } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) } } diff --git a/apps/sim/lib/copilot/tools/server/files/delete-file.ts b/apps/sim/lib/copilot/tools/server/files/delete-file.ts index fba8d4e768..901ac96b3a 100644 --- a/apps/sim/lib/copilot/tools/server/files/delete-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/delete-file.ts @@ -13,7 +13,8 @@ import { const logger = createLogger('DeleteFileServerTool') interface DeleteFileArgs { - fileId: string + fileIds?: string[] + fileId?: string args?: Record } @@ -34,27 +35,41 @@ export const deleteFileServerTool: BaseServerTool 0) parts.push(`Deleted: ${deleted.join(', ')}`) + if (failed.length > 0) parts.push(`Not found: ${failed.join(', ')}`) return { - success: true, - message: `File "${existingFile.name}" deleted successfully`, + success: deleted.length > 0, + message: parts.join('. '), } }, } diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index b34fc6552d..67c7f655f8 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -246,12 +246,13 @@ export const knowledgeBaseServerTool: BaseServerTool = [] + const failedFiles: string[] = [] - if (!fileRecord) { - return { - success: false, - message: `Workspace file not found: "${fileReference}"`, + for (const fileRef of fileIds) { + const fileRecord = await resolveWorkspaceFileReference(kbWorkspaceId, fileRef) + if (!fileRecord) { + failedFiles.push(fileRef) + continue } + + const presignedUrl = await StorageService.generatePresignedDownloadUrl( + fileRecord.key, + 'workspace', + 5 * 60 + ) + + const requestId = generateId().slice(0, 8) + assertNotAborted() + const doc = await createSingleDocument( + { + filename: fileRecord.name, + fileUrl: presignedUrl, + fileSize: fileRecord.size, + mimeType: fileRecord.type, + }, + args.knowledgeBaseId, + requestId + ) + + processDocumentAsync( + args.knowledgeBaseId, + doc.id, + { + filename: fileRecord.name, + fileUrl: presignedUrl, + fileSize: fileRecord.size, + mimeType: fileRecord.type, + }, + {} + ).catch((err) => { + logger.error('Background document processing failed', { + documentId: doc.id, + error: err instanceof Error ? err.message : String(err), + }) + }) + + added.push({ documentId: doc.id, filename: fileRecord.name }) + + logger.info('Workspace file added to knowledge base via copilot', { + knowledgeBaseId: args.knowledgeBaseId, + documentId: doc.id, + fileName: fileRecord.name, + userId: context.userId, + }) } - const presignedUrl = await StorageService.generatePresignedDownloadUrl( - fileRecord.key, - 'workspace', - 5 * 60 - ) - - const requestId = generateId().slice(0, 8) - assertNotAborted() - const doc = await createSingleDocument( - { - filename: fileRecord.name, - fileUrl: presignedUrl, - fileSize: fileRecord.size, - mimeType: fileRecord.type, - }, - args.knowledgeBaseId, - requestId - ) - - processDocumentAsync( - args.knowledgeBaseId, - doc.id, - { - filename: fileRecord.name, - fileUrl: presignedUrl, - fileSize: fileRecord.size, - mimeType: fileRecord.type, - }, - {} - ).catch((err) => { - logger.error('Background document processing failed', { - documentId: doc.id, - error: err instanceof Error ? err.message : String(err), - }) - }) - - logger.info('Workspace file added to knowledge base via copilot', { - knowledgeBaseId: args.knowledgeBaseId, - documentId: doc.id, - fileName: fileRecord.name, - userId: context.userId, - }) - + const addedNames = added.map((a) => a.filename).join(', ') return { - success: true, - message: `File "${fileRecord.name}" added to knowledge base "${targetKb.name}". Processing started (chunking + embedding).`, + success: added.length > 0, + message: + added.length > 0 + ? `Added ${added.length} file(s) to "${targetKb.name}": ${addedNames}. Processing started.` + : `No files could be added.`, data: { - documentId: doc.id, knowledgeBaseId: args.knowledgeBaseId, knowledgeBaseName: targetKb.name, - filename: fileRecord.name, - fileSize: fileRecord.size, - mimeType: fileRecord.type, + added, + failed: failedFiles, }, } } @@ -379,38 +386,44 @@ export const knowledgeBaseServerTool: BaseServerTool = [] + const notFound: string[] = [] + + for (const kbId of kbIds) { + const kbToDelete = await getKnowledgeBaseById(kbId) + if (!kbToDelete) { + notFound.push(kbId) + continue } + + const requestId = generateId().slice(0, 8) + assertNotAborted() + await deleteKnowledgeBase(kbId, requestId) + deleted.push({ id: kbId, name: kbToDelete.name }) + + logger.info('Knowledge base deleted via copilot', { + knowledgeBaseId: kbId, + name: kbToDelete.name, + userId: context.userId, + }) } - const requestId = generateId().slice(0, 8) - assertNotAborted() - await deleteKnowledgeBase(args.knowledgeBaseId, requestId) - - logger.info('Knowledge base deleted via copilot', { - knowledgeBaseId: args.knowledgeBaseId, - name: kbToDelete.name, - userId: context.userId, - }) - return { - success: true, - message: `Knowledge base "${kbToDelete.name}" deleted successfully`, - data: { - id: args.knowledgeBaseId, - name: kbToDelete.name, - }, + success: deleted.length > 0, + message: + deleted.length > 0 + ? `Deleted: ${deleted.map((d) => d.name).join(', ')}` + : 'No knowledge bases found', + data: { deleted, notFound }, } } @@ -418,16 +431,32 @@ export const knowledgeBaseServerTool: BaseServerTool 0, + message: `Deleted ${deleted.length} document(s)${failed.length > 0 ? `, ${failed.length} failed` : ''}`, + data: { knowledgeBaseId: args.knowledgeBaseId, deleted, failed }, } } diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index b69c12c20e..626de7a938 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -335,28 +335,33 @@ export const userTableServerTool: BaseServerTool } case 'delete': { - if (!args.tableId) { - return { success: false, message: 'Table ID is required' } + const tableIds: string[] = args.tableIds ?? (args.tableId ? [args.tableId] : []) + if (tableIds.length === 0) { + return { success: false, message: 'tableId or tableIds is required' } } if (!workspaceId) { return { success: false, message: 'Workspace ID is required' } } - const table = await getTableById(args.tableId) - if (!table) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - if (table.workspaceId !== workspaceId) { - return { success: false, message: 'Table not found' } - } + const deleted: string[] = [] + const failed: string[] = [] - const requestId = generateId().slice(0, 8) - assertNotAborted() - await deleteTable(args.tableId, requestId) + for (const tableId of tableIds) { + const table = await getTableById(tableId) + if (!table || table.workspaceId !== workspaceId) { + failed.push(tableId) + continue + } + + const requestId = generateId().slice(0, 8) + assertNotAborted() + await deleteTable(tableId, requestId) + deleted.push(tableId) + } return { - success: true, - message: `Deleted table ${args.tableId}`, + success: deleted.length > 0, + message: `Deleted ${deleted.length} table(s)${failed.length > 0 ? `, ${failed.length} not found` : ''}`, } } diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 6888fa9f57..b13b322d54 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -10,12 +10,13 @@ import { mcpServers as mcpServersTable, workflowDeploymentVersion, workflowExecutionLogs, + workflowFolder, workflowMcpServer, workflowMcpTool, workflowSchedule, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, desc, eq, isNull, ne } from 'drizzle-orm' +import { and, desc, eq, isNotNull, isNull, ne } from 'drizzle-orm' import { listApiKeys } from '@/lib/api-key/service' import { buildWorkspaceMd, type WorkspaceMdData } from '@/lib/copilot/chat/workspace-context' import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader' @@ -390,6 +391,8 @@ export class WorkspaceVFS { }) ) + await this.materializeRecentlyDeleted(workspaceId, userId) + for (const [path, content] of getStaticComponentFiles()) { this.files.set(path, content) } @@ -401,16 +404,32 @@ export class WorkspaceVFS { }) } + private activeFiles(): Map { + const filtered = new Map() + for (const [key, value] of this.files) { + if (!key.startsWith('recently-deleted/')) { + filtered.set(key, value) + } + } + return filtered + } + + private filesForPath(path?: string): Map { + if (path?.startsWith('recently-deleted')) return this.files + return this.activeFiles() + } + grep( pattern: string, path?: string, options?: GrepOptions ): GrepMatch[] | string[] | ops.GrepCountEntry[] { - return ops.grep(this.files, pattern, path, options) + return ops.grep(this.filesForPath(path), pattern, path, options) } glob(pattern: string): string[] { - return ops.glob(this.files, pattern) + const target = pattern.startsWith('recently-deleted') ? this.files : this.activeFiles() + return ops.glob(target, pattern) } read(path: string, offset?: number, limit?: number): ReadResult | null { @@ -418,7 +437,7 @@ export class WorkspaceVFS { } list(path: string): DirEntry[] { - return ops.list(this.files, path) + return ops.list(this.filesForPath(path), path) } suggestSimilar(missingPath: string, max?: number): string[] { @@ -431,14 +450,18 @@ export class WorkspaceVFS { * Returns null if the path doesn't match `files/{name}` / `files/by-id/{id}` or the file isn't found. */ async readFileContent(path: string): Promise { - const match = path.match(/^files\/(.+?)(?:\/content)?$/) + const deletedMatch = path.match(/^recently-deleted\/files\/(.+?)(?:\/content)?$/) + const activeMatch = path.match(/^files\/(.+?)(?:\/content)?$/) + const match = deletedMatch || activeMatch if (!match) return null const fileName = match[1] if (fileName.endsWith('/meta.json') || path.endsWith('/meta.json')) return null + const scope = deletedMatch ? 'archived' : 'active' + try { - const files = await listWorkspaceFiles(this._workspaceId) + const files = await listWorkspaceFiles(this._workspaceId, { scope }) const record = findWorkspaceFileRecord(files, fileName) if (!record) return null return readFileRecord(record) @@ -1209,6 +1232,103 @@ export class WorkspaceVFS { } } + private async materializeRecentlyDeleted(workspaceId: string, userId: string): Promise { + try { + const [archivedWorkflows, archivedFolders, archivedTables, archivedFiles, archivedKBs] = + await Promise.all([ + listWorkflows(workspaceId, { scope: 'archived' }), + db + .select({ + id: workflowFolder.id, + name: workflowFolder.name, + archivedAt: workflowFolder.archivedAt, + }) + .from(workflowFolder) + .where( + and(eq(workflowFolder.workspaceId, workspaceId), isNotNull(workflowFolder.archivedAt)) + ), + listTables(workspaceId, { scope: 'archived' }), + listWorkspaceFiles(workspaceId, { scope: 'archived' }), + getKnowledgeBases(userId, workspaceId, 'archived'), + ]) + + for (const wf of archivedWorkflows) { + const safeName = sanitizeName(wf.name) + this.files.set( + `recently-deleted/workflows/${safeName}/meta.json`, + serializeWorkflowMeta(wf) + ) + } + + for (const folder of archivedFolders) { + const safeName = sanitizeName(folder.name) + this.files.set( + `recently-deleted/folders/${safeName}/meta.json`, + JSON.stringify( + { id: folder.id, name: folder.name, archivedAt: folder.archivedAt }, + null, + 2 + ) + ) + } + + for (const table of archivedTables) { + const safeName = sanitizeName(table.name) + this.files.set( + `recently-deleted/tables/${safeName}/meta.json`, + serializeTableMeta({ + id: table.id, + name: table.name, + description: table.description, + schema: table.schema, + rowCount: table.rowCount, + maxRows: table.maxRows, + createdAt: table.createdAt, + updatedAt: table.updatedAt, + }) + ) + } + + for (const file of archivedFiles) { + const safeName = sanitizeName(file.name) + this.files.set( + `recently-deleted/files/${safeName}/meta.json`, + serializeFileMeta({ + id: file.id, + name: file.name, + contentType: file.type, + size: file.size, + uploadedAt: file.uploadedAt, + }) + ) + } + + for (const kb of archivedKBs) { + const safeName = sanitizeName(kb.name) + this.files.set( + `recently-deleted/knowledgebases/${safeName}/meta.json`, + serializeKBMeta({ + id: kb.id, + name: kb.name, + description: kb.description, + embeddingModel: kb.embeddingModel, + embeddingDimension: kb.embeddingDimension, + tokenCount: kb.tokenCount, + createdAt: kb.createdAt, + updatedAt: kb.updatedAt, + documentCount: kb.docCount, + connectorTypes: kb.connectorTypes, + }) + ) + } + } catch (err) { + logger.warn('Failed to materialize recently deleted resources', { + workspaceId, + error: err instanceof Error ? err.message : String(err), + }) + } + } + /** * Materialize environment data using shared service functions: * - getAccessibleEnvCredentials for workspace-scoped credentials