Restore and mass delete tools

This commit is contained in:
Siddharth Ganesan
2026-04-09 20:30:12 -07:00
parent 2156f49951
commit c74c4a915f
15 changed files with 953 additions and 392 deletions

View File

@@ -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<string, ToolCatalogEntry> = {
[RenameWorkflow.id]: RenameWorkflow,
[Research.id]: Research,
[Respond.id]: Respond,
[RestoreResource.id]: RestoreResource,
[RevertToVersion.id]: RevertToVersion,
[Run.id]: Run,
[RunBlock.id]: RunBlock,

View File

@@ -289,12 +289,15 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
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<string, ToolRuntimeSchemaEntry> = {
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<string, ToolRuntimeSchemaEntry> = {
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<string, ToolRuntimeSchemaEntry> = {
'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<string, ToolRuntimeSchemaEntry> = {
'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<string, ToolRuntimeSchemaEntry> = {
},
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<string, ToolRuntimeSchemaEntry> = {
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<string, ToolRuntimeSchemaEntry> = {
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<string, ToolRuntimeSchemaEntry> = {
enum: ['rename', 'delete'],
},
},
required: ['operation', 'credentialId'],
required: ['operation'],
},
resultSchema: undefined,
},
@@ -1340,7 +1369,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
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<string, ToolRuntimeSchemaEntry> = {
},
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<string, ToolRuntimeSchemaEntry> = {
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<string, ToolRuntimeSchemaEntry> = {
'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<string, ToolRuntimeSchemaEntry> = {
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<string, ToolRuntimeSchemaEntry> = {
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<string, ToolRuntimeSchemaEntry> = {
},
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<string, ToolRuntimeSchemaEntry> = {
'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<string, ToolRuntimeSchemaEntry> = {
},
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',

View File

@@ -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<string, ToolHandler> {
[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),

View File

@@ -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', {

View File

@@ -7,30 +7,69 @@ export function executeManageCredential(
rawParams: Record<string, unknown>,
_context: ExecutionContext
): Promise<ToolCallResult> {
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,

View File

@@ -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)`,
},
}
}

View File

@@ -186,9 +186,12 @@ export async function executeMaterializeFile(
params: Record<string, unknown>,
context: ExecutionContext
): Promise<ToolCallResult> {
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,
}
}

View File

@@ -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
}

View File

@@ -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<string, unknown>,
context: ExecutionContext
): Promise<ToolCallResult> {
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 } }
}

View File

@@ -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<string, unknown>,
context: ExecutionContext
): Promise<ToolCallResult> {
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) }
}
}

View File

@@ -412,17 +412,27 @@ export async function executeMoveWorkflow(
context: ExecutionContext
): Promise<ToolCallResult> {
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<ToolCallResult> {
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<ToolCallResult> {
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) }
}

View File

@@ -13,7 +13,8 @@ import {
const logger = createLogger('DeleteFileServerTool')
interface DeleteFileArgs {
fileId: string
fileIds?: string[]
fileId?: string
args?: Record<string, unknown>
}
@@ -34,27 +35,41 @@ export const deleteFileServerTool: BaseServerTool<DeleteFileArgs, DeleteFileResu
}
const nested = params.args
const fileId = params.fileId || (nested?.fileId as string) || ''
const fileIds: string[] =
params.fileIds ??
(nested?.fileIds as string[] | undefined) ??
[params.fileId || (nested?.fileId as string) || ''].filter(Boolean)
if (!fileId) return { success: false, message: 'fileId is required' }
if (fileIds.length === 0) return { success: false, message: 'fileIds is required' }
const existingFile = await getWorkspaceFile(workspaceId, fileId)
if (!existingFile) {
return { success: false, message: `File with ID "${fileId}" not found` }
const deleted: string[] = []
const failed: string[] = []
for (const fileId of fileIds) {
const existingFile = await getWorkspaceFile(workspaceId, fileId)
if (!existingFile) {
failed.push(fileId)
continue
}
assertServerToolNotAborted(context)
await deleteWorkspaceFile(workspaceId, fileId)
deleted.push(existingFile.name)
logger.info('File deleted via delete_file', {
fileId,
name: existingFile.name,
userId: context.userId,
})
}
assertServerToolNotAborted(context)
await deleteWorkspaceFile(workspaceId, fileId)
logger.info('File deleted via delete_file', {
fileId,
name: existingFile.name,
userId: context.userId,
})
const parts: string[] = []
if (deleted.length > 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('. '),
}
},
}

View File

@@ -246,12 +246,13 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
}
}
const fileReference = args.fileId || args.filePath
if (!fileReference) {
const fileIds: string[] =
args.fileIds ?? (args.fileId ? [args.fileId] : args.filePath ? [args.filePath] : [])
if (fileIds.length === 0) {
return {
success: false,
message:
'fileId is required for add_file. Read files/{name}/meta.json or files/by-id/*/meta.json to get the canonical file ID.',
'fileIds is required for add_file. Read files/{name}/meta.json or files/by-id/*/meta.json to get the canonical file IDs.',
}
}
@@ -264,68 +265,74 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
}
const kbWorkspaceId: string = targetKb.workspaceId
const fileRecord = await resolveWorkspaceFileReference(kbWorkspaceId, fileReference)
const added: Array<{ documentId: string; filename: string }> = []
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<KnowledgeBaseArgs, Knowledg
}
case 'delete': {
if (!args.knowledgeBaseId) {
const kbIds: string[] =
args.knowledgeBaseIds ?? (args.knowledgeBaseId ? [args.knowledgeBaseId] : [])
if (kbIds.length === 0) {
return {
success: false,
message: 'Knowledge base ID is required for delete operation',
message: 'knowledgeBaseId or knowledgeBaseIds is required for delete operation',
}
}
const kbToDelete = await getKnowledgeBaseById(args.knowledgeBaseId)
if (!kbToDelete) {
return {
success: false,
message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`,
const deleted: Array<{ id: string; name: string }> = []
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<KnowledgeBaseArgs, Knowledg
if (!args.knowledgeBaseId) {
return { success: false, message: 'knowledgeBaseId is required for delete_document' }
}
if (!args.documentId) {
return { success: false, message: 'documentId is required for delete_document' }
const docIds: string[] = args.documentIds ?? (args.documentId ? [args.documentId] : [])
if (docIds.length === 0) {
return {
success: false,
message: 'documentId or documentIds is required for delete_document',
}
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
const result = await deleteDocument(args.documentId, requestId)
const deleted: string[] = []
const failed: string[] = []
for (const docId of docIds) {
const requestId = generateId().slice(0, 8)
assertNotAborted()
const result = await deleteDocument(docId, requestId)
if (result.success) {
deleted.push(docId)
} else {
failed.push(docId)
}
}
return {
success: result.success,
message: result.message,
data: { documentId: args.documentId, knowledgeBaseId: args.knowledgeBaseId },
success: deleted.length > 0,
message: `Deleted ${deleted.length} document(s)${failed.length > 0 ? `, ${failed.length} failed` : ''}`,
data: { knowledgeBaseId: args.knowledgeBaseId, deleted, failed },
}
}

View File

@@ -335,28 +335,33 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
}
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` : ''}`,
}
}

View File

@@ -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<string, string> {
const filtered = new Map<string, string>()
for (const [key, value] of this.files) {
if (!key.startsWith('recently-deleted/')) {
filtered.set(key, value)
}
}
return filtered
}
private filesForPath(path?: string): Map<string, string> {
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<FileReadResult | null> {
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<void> {
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