Checkpoint

This commit is contained in:
Siddharth Ganesan
2026-02-19 10:14:24 -08:00
parent 3338b25c30
commit 459c2930ae
11 changed files with 722 additions and 8 deletions

View File

@@ -10,6 +10,9 @@ export const INTERRUPT_TOOL_NAMES = [
'deploy_chat',
'deploy_api',
'create_workspace_mcp_server',
'update_workspace_mcp_server',
'delete_workspace_mcp_server',
'delete_workflow',
'set_environment_variables',
'make_api_request',
'oauth_request_access',

View File

@@ -217,13 +217,6 @@ export async function executeDeployMcp(
return { success: false, error: 'workspaceId is required' }
}
if (!workflowRecord.isDeployed) {
return {
success: false,
error: 'Workflow must be deployed before adding as an MCP tool. Use deploy_api first.',
}
}
const serverId = params.serverId
if (!serverId) {
return {
@@ -232,6 +225,34 @@ export async function executeDeployMcp(
}
}
// Handle undeploy action — remove workflow from MCP server
if (params.action === 'undeploy') {
const deleted = await db
.delete(workflowMcpTool)
.where(
and(eq(workflowMcpTool.serverId, serverId), eq(workflowMcpTool.workflowId, workflowId))
)
.returning({ id: workflowMcpTool.id })
if (deleted.length === 0) {
return { success: false, error: 'Workflow is not deployed to this MCP server' }
}
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
return {
success: true,
output: { workflowId, serverId, action: 'undeploy', removed: true },
}
}
if (!workflowRecord.isDeployed) {
return {
success: false,
error: 'Workflow must be deployed before adding as an MCP tool. Use deploy_api first.',
}
}
const existingTool = await db
.select()
.from(workflowMcpTool)

View File

@@ -11,7 +11,9 @@ import { ensureWorkflowAccess } from '../access'
import type {
CheckDeploymentStatusParams,
CreateWorkspaceMcpServerParams,
DeleteWorkspaceMcpServerParams,
ListWorkspaceMcpServersParams,
UpdateWorkspaceMcpServerParams,
} from '../param-types'
export async function executeCheckDeploymentStatus(
@@ -231,3 +233,82 @@ export async function executeCreateWorkspaceMcpServer(
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function executeUpdateWorkspaceMcpServer(
params: UpdateWorkspaceMcpServerParams,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const serverId = params.serverId
if (!serverId) {
return { success: false, error: 'serverId is required' }
}
const updates: Record<string, unknown> = { updatedAt: new Date() }
if (typeof params.name === 'string') {
const name = params.name.trim()
if (!name) return { success: false, error: 'name cannot be empty' }
updates.name = name
}
if (typeof params.description === 'string') {
updates.description = params.description.trim() || null
}
if (typeof params.isPublic === 'boolean') {
updates.isPublic = params.isPublic
}
if (Object.keys(updates).length <= 1) {
return { success: false, error: 'At least one of name, description, or isPublic is required' }
}
const [existing] = await db
.select({ id: workflowMcpServer.id, createdBy: workflowMcpServer.createdBy })
.from(workflowMcpServer)
.where(eq(workflowMcpServer.id, serverId))
.limit(1)
if (!existing) {
return { success: false, error: 'MCP server not found' }
}
await db
.update(workflowMcpServer)
.set(updates)
.where(eq(workflowMcpServer.id, serverId))
return { success: true, output: { serverId, ...updates, updatedAt: undefined } }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function executeDeleteWorkspaceMcpServer(
params: DeleteWorkspaceMcpServerParams,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const serverId = params.serverId
if (!serverId) {
return { success: false, error: 'serverId is required' }
}
const [existing] = await db
.select({ id: workflowMcpServer.id, name: workflowMcpServer.name, workspaceId: workflowMcpServer.workspaceId })
.from(workflowMcpServer)
.where(eq(workflowMcpServer.id, serverId))
.limit(1)
if (!existing) {
return { success: false, error: 'MCP server not found' }
}
await db.delete(workflowMcpServer).where(eq(workflowMcpServer.id, serverId))
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId: existing.workspaceId })
return { success: true, output: { serverId, name: existing.name, deleted: true } }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}

View File

@@ -17,11 +17,13 @@ import { getTool, resolveToolId } from '@/tools/utils'
import {
executeCheckDeploymentStatus,
executeCreateWorkspaceMcpServer,
executeDeleteWorkspaceMcpServer,
executeDeployApi,
executeDeployChat,
executeDeployMcp,
executeListWorkspaceMcpServers,
executeRedeploy,
executeUpdateWorkspaceMcpServer,
} from './deployment-tools'
import { executeIntegrationToolDirect } from './integration-tools'
import {
@@ -35,6 +37,9 @@ import type {
CreateFolderParams,
CreateWorkflowParams,
CreateWorkspaceMcpServerParams,
DeleteFolderParams,
DeleteWorkflowParams,
DeleteWorkspaceMcpServerParams,
DeployApiParams,
DeployChatParams,
DeployMcpParams,
@@ -47,17 +52,22 @@ import type {
ListWorkspaceMcpServersParams,
MoveFolderParams,
MoveWorkflowParams,
RenameFolderParams,
RenameWorkflowParams,
RunBlockParams,
RunFromBlockParams,
RunWorkflowParams,
RunWorkflowUntilBlockParams,
SetGlobalWorkflowVariablesParams,
UpdateWorkflowParams,
UpdateWorkspaceMcpServerParams,
} from './param-types'
import { PLATFORM_ACTIONS_CONTENT } from './platform-actions'
import {
executeCreateFolder,
executeCreateWorkflow,
executeDeleteFolder,
executeDeleteWorkflow,
executeGenerateApiKey,
executeGetBlockOutputs,
executeGetBlockUpstreamReferences,
@@ -67,12 +77,14 @@ import {
executeListUserWorkspaces,
executeMoveFolder,
executeMoveWorkflow,
executeRenameFolder,
executeRenameWorkflow,
executeRunBlock,
executeRunFromBlock,
executeRunWorkflow,
executeRunWorkflowUntilBlock,
executeSetGlobalWorkflowVariables,
executeUpdateWorkflow,
} from './workflow-tools'
const logger = createLogger('CopilotToolExecutor')
@@ -339,8 +351,12 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record<
create_workflow: (p, c) => executeCreateWorkflow(p as CreateWorkflowParams, c),
create_folder: (p, c) => executeCreateFolder(p as CreateFolderParams, c),
rename_workflow: (p, c) => executeRenameWorkflow(p as unknown as RenameWorkflowParams, c),
update_workflow: (p, c) => executeUpdateWorkflow(p as unknown as UpdateWorkflowParams, c),
delete_workflow: (p, c) => executeDeleteWorkflow(p as unknown as DeleteWorkflowParams, c),
move_workflow: (p, c) => executeMoveWorkflow(p as unknown as MoveWorkflowParams, c),
move_folder: (p, c) => executeMoveFolder(p as unknown as MoveFolderParams, c),
rename_folder: (p, c) => executeRenameFolder(p as unknown as RenameFolderParams, c),
delete_folder: (p, c) => executeDeleteFolder(p as unknown as DeleteFolderParams, c),
get_workflow_data: (p, c) => executeGetWorkflowData(p as GetWorkflowDataParams, c),
get_block_outputs: (p, c) => executeGetBlockOutputs(p as GetBlockOutputsParams, c),
get_block_upstream_references: (p, c) =>
@@ -370,6 +386,10 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record<
executeListWorkspaceMcpServers(p as ListWorkspaceMcpServersParams, c),
create_workspace_mcp_server: (p, c) =>
executeCreateWorkspaceMcpServer(p as CreateWorkspaceMcpServerParams, c),
update_workspace_mcp_server: (p, c) =>
executeUpdateWorkspaceMcpServer(p as unknown as UpdateWorkspaceMcpServerParams, c),
delete_workspace_mcp_server: (p, c) =>
executeDeleteWorkspaceMcpServer(p as unknown as DeleteWorkspaceMcpServerParams, c),
oauth_get_auth_link: async (p, _c) => {
const providerName = (p.providerName || p.provider_name || 'the provider') as string
try {

View File

@@ -163,6 +163,16 @@ export interface RenameWorkflowParams {
name: string
}
export interface UpdateWorkflowParams {
workflowId: string
name?: string
description?: string
}
export interface DeleteWorkflowParams {
workflowId: string
}
export interface MoveWorkflowParams {
workflowId: string
folderId: string | null
@@ -172,3 +182,23 @@ export interface MoveFolderParams {
folderId: string
parentId: string | null
}
export interface RenameFolderParams {
folderId: string
name: string
}
export interface DeleteFolderParams {
folderId: string
}
export interface UpdateWorkspaceMcpServerParams {
serverId: string
name?: string
description?: string
isPublic?: boolean
}
export interface DeleteWorkspaceMcpServerParams {
serverId: string
}

View File

@@ -18,15 +18,19 @@ import { ensureWorkflowAccess, ensureWorkspaceAccess, getDefaultWorkspaceId } fr
import type {
CreateFolderParams,
CreateWorkflowParams,
DeleteFolderParams,
DeleteWorkflowParams,
GenerateApiKeyParams,
MoveFolderParams,
MoveWorkflowParams,
RenameFolderParams,
RenameWorkflowParams,
RunBlockParams,
RunFromBlockParams,
RunWorkflowParams,
RunWorkflowUntilBlockParams,
SetGlobalWorkflowVariablesParams,
UpdateWorkflowParams,
VariableOperation,
} from '../param-types'
@@ -566,6 +570,142 @@ export async function executeRunFromBlock(
}
}
export async function executeUpdateWorkflow(
params: UpdateWorkflowParams,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
const updates: Record<string, unknown> = { updatedAt: new Date() }
if (typeof params.name === 'string') {
const name = params.name.trim()
if (!name) return { success: false, error: 'name cannot be empty' }
if (name.length > 200) return { success: false, error: 'Workflow name must be 200 characters or less' }
updates.name = name
}
if (typeof params.description === 'string') {
if (params.description.length > 2000) {
return { success: false, error: 'Description must be 2000 characters or less' }
}
updates.description = params.description
}
if (Object.keys(updates).length <= 1) {
return { success: false, error: 'At least one of name or description is required' }
}
await ensureWorkflowAccess(workflowId, context.userId)
await db.update(workflow).set(updates).where(eq(workflow.id, workflowId))
return {
success: true,
output: { workflowId, ...updates, updatedAt: undefined },
}
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function executeDeleteWorkflow(
params: DeleteWorkflowParams,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
await db.delete(workflow).where(eq(workflow.id, workflowId))
return {
success: true,
output: { workflowId, name: workflowRecord.name, deleted: true },
}
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function executeRenameFolder(
params: RenameFolderParams,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const folderId = params.folderId
if (!folderId) {
return { success: false, error: 'folderId is required' }
}
const name = typeof params.name === 'string' ? params.name.trim() : ''
if (!name) {
return { success: false, error: 'name is required' }
}
if (name.length > 200) {
return { success: false, error: 'Folder name must be 200 characters or less' }
}
await db
.update(workflowFolder)
.set({ name, updatedAt: new Date() })
.where(eq(workflowFolder.id, folderId))
return { success: true, output: { folderId, name } }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function executeDeleteFolder(
params: DeleteFolderParams,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const folderId = params.folderId
if (!folderId) {
return { success: false, error: 'folderId is required' }
}
// Get the folder to find its parent
const [folder] = await db
.select({ parentId: workflowFolder.parentId })
.from(workflowFolder)
.where(eq(workflowFolder.id, folderId))
.limit(1)
if (!folder) {
return { success: false, error: 'Folder not found' }
}
// Move child workflows to parent folder
await db
.update(workflow)
.set({ folderId: folder.parentId, updatedAt: new Date() })
.where(eq(workflow.folderId, folderId))
// Move child folders to parent folder
await db
.update(workflowFolder)
.set({ parentId: folder.parentId, updatedAt: new Date() })
.where(eq(workflowFolder.parentId, folderId))
// Delete the folder
await db.delete(workflowFolder).where(eq(workflowFolder.id, folderId))
return { success: true, output: { folderId, deleted: true } }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function executeRunBlock(
params: RunBlockParams,
context: ExecutionContext

View File

@@ -4,8 +4,10 @@ import type { KnowledgeBaseArgs, KnowledgeBaseResult } from '@/lib/copilot/tools
import { generateSearchEmbedding } from '@/lib/knowledge/embeddings'
import {
createKnowledgeBase,
deleteKnowledgeBase,
getKnowledgeBaseById,
getKnowledgeBases,
updateKnowledgeBase,
} from '@/lib/knowledge/service'
import {
createTagDefinition,
@@ -221,6 +223,83 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
}
}
case 'update': {
if (!args.knowledgeBaseId) {
return {
success: false,
message: 'Knowledge base ID is required for update operation',
}
}
const updates: { name?: string; description?: string; chunkingConfig?: { maxSize: number; minSize: number; overlap: number } } = {}
if (args.name) updates.name = args.name
if (args.description !== undefined) updates.description = args.description
if (args.chunkingConfig) updates.chunkingConfig = args.chunkingConfig
if (!updates.name && updates.description === undefined && !updates.chunkingConfig) {
return {
success: false,
message: 'At least one of name, description, or chunkingConfig is required for update',
}
}
const requestId = crypto.randomUUID().slice(0, 8)
const updatedKb = await updateKnowledgeBase(args.knowledgeBaseId, updates, requestId)
logger.info('Knowledge base updated via copilot', {
knowledgeBaseId: args.knowledgeBaseId,
userId: context.userId,
})
return {
success: true,
message: `Knowledge base "${updatedKb.name}" updated successfully`,
data: {
id: updatedKb.id,
name: updatedKb.name,
description: updatedKb.description,
workspaceId: updatedKb.workspaceId,
docCount: updatedKb.docCount,
updatedAt: updatedKb.updatedAt,
},
}
}
case 'delete': {
if (!args.knowledgeBaseId) {
return {
success: false,
message: 'Knowledge base ID 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 requestId = crypto.randomUUID().slice(0, 8)
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,
},
}
}
case 'list_tags': {
if (!args.knowledgeBaseId) {
return {
@@ -391,7 +470,7 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
default:
return {
success: false,
message: `Unknown operation: ${operation}. Supported operations: create, list, get, query, list_tags, create_tag, update_tag, delete_tag, get_tag_usage`,
message: `Unknown operation: ${operation}. Supported operations: create, list, get, query, update, delete, list_tags, create_tag, update_tag, delete_tag, get_tag_usage`,
}
}
} catch (error) {

View File

@@ -26,6 +26,8 @@ export const KnowledgeBaseArgsSchema = z.object({
'list',
'get',
'query',
'update',
'delete',
'list_tags',
'create_tag',
'update_tag',

View File

@@ -243,6 +243,140 @@ export function serializeEnvironmentVariables(
)
}
/** Input types for deployment serialization. */
export interface DeploymentData {
workflowId: string
isDeployed: boolean
deployedAt?: Date | null
api?: {
version: number
createdAt: Date
} | null
chat?: {
id: string
identifier: string
title: string
description?: string | null
authType: string
customizations: unknown
isActive: boolean
} | null
form?: {
id: string
identifier: string
title: string
description?: string | null
authType: string
showBranding: boolean
customizations: unknown
isActive: boolean
} | null
mcp: Array<{
serverId: string
serverName: string
toolId: string
toolName: string
toolDescription?: string | null
}>
a2a?: {
id: string
name: string
description?: string | null
version: string
isPublished: boolean
capabilities: unknown
} | null
}
/**
* Serialize all deployment configurations for VFS deployment.json.
* Only includes keys for active deployment types.
*/
export function serializeDeployments(data: DeploymentData): string {
const result: Record<string, unknown> = {}
if (data.isDeployed) {
result.api = {
isDeployed: true,
deployedAt: data.deployedAt?.toISOString(),
apiEndpoint: `/api/workflows/${data.workflowId}/run`,
...(data.api ? { version: data.api.version } : {}),
}
}
if (data.chat) {
result.chat = {
id: data.chat.id,
identifier: data.chat.identifier,
chatUrl: `/chat/${data.chat.identifier}`,
title: data.chat.title,
description: data.chat.description || undefined,
authType: data.chat.authType,
customizations: data.chat.customizations,
isActive: data.chat.isActive,
}
}
if (data.form) {
result.form = {
id: data.form.id,
identifier: data.form.identifier,
formUrl: `/form/${data.form.identifier}`,
title: data.form.title,
description: data.form.description || undefined,
authType: data.form.authType,
showBranding: data.form.showBranding,
customizations: data.form.customizations,
isActive: data.form.isActive,
}
}
if (data.mcp.length > 0) {
result.mcp = data.mcp.map((m) => ({
serverId: m.serverId,
serverName: m.serverName,
toolId: m.toolId,
toolName: m.toolName,
toolDescription: m.toolDescription || undefined,
}))
}
if (data.a2a) {
result.a2a = {
id: data.a2a.id,
name: data.a2a.name,
description: data.a2a.description || undefined,
version: data.a2a.version,
isPublished: data.a2a.isPublished,
capabilities: data.a2a.capabilities,
agentUrl: `/api/a2a/serve/${data.a2a.id}`,
}
}
return JSON.stringify(result, null, 2)
}
/**
* Serialize a custom tool for VFS custom-tools/{name}.json
*/
export function serializeCustomTool(tool: {
id: string
title: string
schema: unknown
code: string
}): string {
return JSON.stringify(
{
id: tool.id,
title: tool.title,
schema: tool.schema,
codePreview: tool.code.length > 500 ? tool.code.slice(0, 500) + '...' : tool.code,
},
null,
2
)
}
/**
* Serialize an integration/tool schema for VFS components/integrations/{service}/{operation}.json
*/

View File

@@ -1,11 +1,18 @@
import { db } from '@sim/db'
import {
a2aAgent,
account,
apiKey,
chat as chatTable,
customTools,
document,
environment,
form,
knowledgeBase,
workflow,
workflowDeploymentVersion,
workflowMcpServer,
workflowMcpTool,
workspaceEnvironment,
workflowExecutionLogs,
} from '@sim/db/schema'
@@ -22,6 +29,8 @@ import {
serializeApiKeys,
serializeBlockSchema,
serializeCredentials,
serializeCustomTool,
serializeDeployments,
serializeDocuments,
serializeEnvironmentVariables,
serializeIntegrationSchema,
@@ -29,6 +38,7 @@ import {
serializeRecentExecutions,
serializeWorkflowMeta,
} from '@/lib/copilot/vfs/serializers'
import type { DeploymentData } from '@/lib/copilot/vfs/serializers'
const logger = createLogger('WorkspaceVFS')
@@ -114,8 +124,10 @@ function getStaticComponentFiles(): Map<string, string> {
* workflows/{name}/blocks.json
* workflows/{name}/edges.json
* workflows/{name}/executions.json
* workflows/{name}/deployment.json
* knowledgebases/{name}/meta.json
* knowledgebases/{name}/documents.json
* custom-tools/{name}.json
* environment/credentials.json
* environment/api-keys.json
* environment/variables.json
@@ -137,6 +149,7 @@ export class WorkspaceVFS {
this.materializeWorkflows(workspaceId, userId),
this.materializeKnowledgeBases(workspaceId),
this.materializeEnvironment(workspaceId, userId),
this.materializeCustomTools(workspaceId),
])
// Merge static component files
@@ -253,6 +266,19 @@ export class WorkspaceVFS {
error: err instanceof Error ? err.message : String(err),
})
}
// Deployment configuration
try {
const deploymentData = await this.getWorkflowDeployments(wf.id, workspaceId, wf.isDeployed, wf.deployedAt)
if (deploymentData) {
this.files.set(`${prefix}deployment.json`, serializeDeployments(deploymentData))
}
} catch (err) {
logger.warn('Failed to load deployment data', {
workflowId: wf.id,
error: err instanceof Error ? err.message : String(err),
})
}
})
)
}
@@ -317,6 +343,134 @@ export class WorkspaceVFS {
)
}
/**
* Query all deployment configurations for a single workflow.
* Returns null if the workflow has no deployments of any kind.
*/
private async getWorkflowDeployments(
workflowId: string,
workspaceId: string,
isDeployed: boolean,
deployedAt: Date | null
): Promise<DeploymentData | null> {
const [chatRows, formRows, mcpRows, a2aRows, versionRows] = await Promise.all([
db
.select({
id: chatTable.id,
identifier: chatTable.identifier,
title: chatTable.title,
description: chatTable.description,
authType: chatTable.authType,
customizations: chatTable.customizations,
isActive: chatTable.isActive,
})
.from(chatTable)
.where(eq(chatTable.workflowId, workflowId)),
db
.select({
id: form.id,
identifier: form.identifier,
title: form.title,
description: form.description,
authType: form.authType,
showBranding: form.showBranding,
customizations: form.customizations,
isActive: form.isActive,
})
.from(form)
.where(eq(form.workflowId, workflowId)),
db
.select({
serverId: workflowMcpTool.serverId,
serverName: workflowMcpServer.name,
toolId: workflowMcpTool.id,
toolName: workflowMcpTool.toolName,
toolDescription: workflowMcpTool.toolDescription,
})
.from(workflowMcpTool)
.innerJoin(workflowMcpServer, eq(workflowMcpTool.serverId, workflowMcpServer.id))
.where(eq(workflowMcpTool.workflowId, workflowId)),
db
.select({
id: a2aAgent.id,
name: a2aAgent.name,
description: a2aAgent.description,
version: a2aAgent.version,
isPublished: a2aAgent.isPublished,
capabilities: a2aAgent.capabilities,
})
.from(a2aAgent)
.where(
and(eq(a2aAgent.workflowId, workflowId), eq(a2aAgent.workspaceId, workspaceId))
),
isDeployed
? db
.select({
version: workflowDeploymentVersion.version,
createdAt: workflowDeploymentVersion.createdAt,
})
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
: Promise.resolve([]),
])
const hasAnyDeployment =
isDeployed || chatRows.length > 0 || formRows.length > 0 || mcpRows.length > 0 || a2aRows.length > 0
if (!hasAnyDeployment) return null
return {
workflowId,
isDeployed,
deployedAt,
api: versionRows[0] ?? null,
chat: chatRows[0] ?? null,
form: formRows[0] ?? null,
mcp: mcpRows,
a2a: a2aRows[0] ?? null,
}
}
/**
* Materialize all custom tools in the workspace.
*/
private async materializeCustomTools(workspaceId: string): Promise<void> {
try {
const toolRows = await db
.select({
id: customTools.id,
title: customTools.title,
schema: customTools.schema,
code: customTools.code,
})
.from(customTools)
.where(eq(customTools.workspaceId, workspaceId))
for (const tool of toolRows) {
const safeName = sanitizeName(tool.title)
this.files.set(
`custom-tools/${safeName}.json`,
serializeCustomTool({
id: tool.id,
title: tool.title,
schema: tool.schema,
code: tool.code,
})
)
}
} catch (err) {
logger.warn('Failed to materialize custom tools', {
workspaceId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Materialize environment data: credentials, API keys, env variable names.
*/
@@ -422,3 +576,18 @@ function sanitizeName(name: string): string {
.replace(/^-|-$/g, '')
.slice(0, 64)
}
/**
* Deduplicate a sanitized name against a set of already-used names.
* Appends `-2`, `-3`, etc. on collision. Adds the final name to the set.
*/
function deduplicateName(baseName: string, usedNames: Set<string>): string {
let name = baseName
let counter = 2
while (usedNames.has(name)) {
name = `${baseName}-${counter}`
counter++
}
usedNames.add(name)
return name
}

View File

@@ -47,6 +47,41 @@ You have access to these specialized subagents. Call them by name to delegate ta
- **memory_file_read(file_path)** — Read a persistent memory file.
- **memory_file_write(file_path, content)** — Write/update a persistent memory file.
- **memory_file_list()** — List all memory files.
- **grep(pattern, path?)** — Search workspace VFS file contents.
- **glob(pattern)** — Find workspace VFS files by path pattern.
- **read(path)** — Read a workspace VFS file.
- **list(path)** — List workspace VFS directory entries.
- **create_workflow(name, description?)** — Create a new workflow.
- **update_workflow(workflowId, name?, description?)** — Update workflow name or description.
- **delete_workflow(workflowId)** — Delete a workflow.
- **rename_folder(folderId, name)** — Rename a folder.
- **delete_folder(folderId)** — Delete a folder (moves contents to parent).
## Workspace Virtual Filesystem (VFS)
Your workspace data is available as a virtual filesystem. Use grep/glob/read/list to explore it before taking action.
\`\`\`
workflows/{name}/
meta.json — name, description, id, run stats
blocks.json — workflow block graph (sanitized)
edges.json — block connections
executions.json — last 5 run results
deployment.json — all deployment configs (api, chat, form, mcp, a2a)
knowledgebases/{name}/
meta.json — KB identity, embedding config, stats
documents.json — document metadata
custom-tools/{name}.json — custom tool schema + code preview
environment/
credentials.json — connected OAuth providers
api-keys.json — API key metadata (names, not values)
variables.json — env variable names (not values)
components/
blocks/{type}.json — block type schemas
integrations/{svc}/{op}.json — integration tool schemas
\`\`\`
**Tips**: Use \`glob("workflows/*/deployment.json")\` to see which workflows are deployed and how. Use \`grep("error", "workflows/")\` to find workflows with recent errors.
## Memory Management