import { truncate } from '@sim/utils/string' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import { isHosted } from '@/lib/core/config/env-flags' import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility' import { isCustomBlockType } from '@/blocks/custom/build-config' import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import { DYNAMIC_MODEL_PROVIDERS, PROVIDER_DEFINITIONS } from '@/providers/models' import type { ToolConfig } from '@/tools/types' /** * Serialize workflow metadata for VFS meta.json. * * `locked` is the EFFECTIVE lock — true when the workflow is locked directly or * sits inside a locked folder. A locked workflow cannot be edited, moved, * renamed, or deleted (mutations are rejected server-side with a 423). The * mothership should read this before attempting any workflow mutation. * `inheritedFolderLock` carries the resolved containing-folder lock (the * caller computes folder inheritance; see workspace-vfs materializeWorkflows). */ export function serializeWorkflowMeta( wf: { id: string name: string description?: string | null folderId?: string | null isDeployed: boolean deployedAt?: Date | null runCount: number lastRunAt?: Date | null createdAt: Date updatedAt: Date locked?: boolean }, options?: { inheritedFolderLock?: boolean } ): string { const directLock = wf.locked ?? false const locked = directLock || (options?.inheritedFolderLock ?? false) return JSON.stringify( { id: wf.id, name: wf.name, description: wf.description || undefined, folderId: wf.folderId || undefined, locked, lockedBy: locked ? (directLock ? 'workflow' : 'folder') : undefined, isDeployed: wf.isDeployed, deployedAt: wf.deployedAt?.toISOString(), runCount: wf.runCount, lastRunAt: wf.lastRunAt?.toISOString(), createdAt: wf.createdAt.toISOString(), updatedAt: wf.updatedAt.toISOString(), }, null, 2 ) } /** * Serialize execution logs for VFS executions.json. * Takes recent execution log rows and produces a summary. */ export function serializeRecentExecutions( executions: Array<{ id: string executionId: string status: string trigger: string startedAt: Date endedAt?: Date | null totalDurationMs?: number | null }> ): string { return JSON.stringify( executions.map((e) => ({ executionId: e.executionId, status: e.status, trigger: e.trigger, startedAt: e.startedAt.toISOString(), endedAt: e.endedAt?.toISOString(), durationMs: e.totalDurationMs, })), null, 2 ) } /** * Serialize knowledge base metadata for VFS meta.json */ export function serializeKBMeta(kb: { id: string name: string description?: string | null embeddingModel: string embeddingDimension: number tokenCount: number createdAt: Date updatedAt: Date documentCount: number connectorTypes?: string[] }): string { return JSON.stringify( { id: kb.id, name: kb.name, description: kb.description || undefined, embeddingModel: kb.embeddingModel, embeddingDimension: kb.embeddingDimension, tokenCount: kb.tokenCount, documentCount: kb.documentCount, connectorTypes: kb.connectorTypes && kb.connectorTypes.length > 0 ? kb.connectorTypes : undefined, createdAt: kb.createdAt.toISOString(), updatedAt: kb.updatedAt.toISOString(), }, null, 2 ) } /** * Serialize documents list for VFS documents.json (metadata only, no content) */ export function serializeDocuments( docs: Array<{ id: string filename: string fileSize: number mimeType: string chunkCount: number tokenCount: number processingStatus: string enabled: boolean uploadedAt: Date }> ): string { return JSON.stringify( docs.map((d) => ({ id: d.id, filename: d.filename, fileSize: d.fileSize, mimeType: d.mimeType, chunkCount: d.chunkCount, tokenCount: d.tokenCount, processingStatus: d.processingStatus, enabled: d.enabled, uploadedAt: d.uploadedAt.toISOString(), })), null, 2 ) } /** * Serialize KB connectors for VFS knowledgebases/{name}/connectors.json. * Shows connector type, sync status, and schedule — NOT credentials or source config. */ export function serializeConnectors( connectors: Array<{ id: string connectorType: string status: string syncMode: string syncIntervalMinutes: number lastSyncAt: Date | null lastSyncError: string | null lastSyncDocCount: number | null nextSyncAt: Date | null consecutiveFailures: number createdAt: Date }> ): string { return JSON.stringify( connectors.map((c) => ({ id: c.id, connectorType: c.connectorType, status: c.status, syncMode: c.syncMode, syncIntervalMinutes: c.syncIntervalMinutes, lastSyncAt: c.lastSyncAt?.toISOString(), lastSyncError: c.lastSyncError || undefined, lastSyncDocCount: c.lastSyncDocCount ?? undefined, nextSyncAt: c.nextSyncAt?.toISOString(), consecutiveFailures: c.consecutiveFailures, createdAt: c.createdAt.toISOString(), })), null, 2 ) } /** * Connector config field shape (mirrors ConnectorConfigField from connectors/types.ts * but avoids importing React-dependent code into serializers). */ interface SerializableConfigField { id: string title: string type: string placeholder?: string required?: boolean description?: string options?: Array<{ label: string; id: string }> } interface SerializableTagDef { id: string displayName: string fieldType: string } interface SerializableConnectorConfig { id: string name: string description: string version: string auth: { mode: string; provider?: string; requiredScopes?: string[] } configFields: SerializableConfigField[] tagDefinitions?: SerializableTagDef[] supportsIncrementalSync?: boolean } /** * Serialize a single connector type's schema for VFS knowledgebases/connectors/{type}.json. * Contains everything the LLM needs to build a valid sourceConfig. */ export function serializeConnectorSchema(connector: SerializableConnectorConfig): string { return JSON.stringify( { id: connector.id, name: connector.name, description: connector.description, version: connector.version, auth: connector.auth, configFields: connector.configFields.map((f) => { const field: Record = { id: f.id, title: f.title, type: f.type, } if (f.required) field.required = true if (f.placeholder) field.placeholder = f.placeholder if (f.description) field.description = f.description if (f.options) field.options = f.options return field }), tagDefinitions: connector.tagDefinitions ?? [], supportsIncrementalSync: connector.supportsIncrementalSync ?? false, }, null, 2 ) } /** * Generate the knowledgebases/connectors/connectors.md overview file. * Lists all available connector types with their OAuth providers — enough * for the LLM to identify the right type and credential, then read the * per-connector schema file for full config details. */ export function serializeConnectorOverview(connectors: SerializableConnectorConfig[]): string { const rows = connectors.map((c) => { const provider = c.auth.provider ?? c.auth.mode const scopes = c.auth.requiredScopes?.length ? c.auth.requiredScopes.join(', ') : '(none)' return `| ${c.id} | ${c.name} | ${provider} | ${scopes} |` }) return [ '# Available KB Connectors', '', 'Use `read("knowledgebases/connectors/{type}.json")` to get the full config schema before calling `add_connector`.', '', '| Type | Name | OAuth Provider | Required Scopes |', '|------|------|---------------|-----------------|', ...rows, '', 'To add a connector, the user must have an OAuth credential for that provider.', 'Check `environment/credentials.json` for available credential IDs.', ].join('\n') } /** * Serialize workspace file metadata for VFS files/{path}/{name}/meta.json. */ export function serializeFileMeta(file: { id: string name: string folderId?: string | null folderPath?: string | null vfsPath?: string contentType: string size: number uploadedAt: Date }): string { return JSON.stringify( { id: file.id, name: file.name, folderId: file.folderId || undefined, folderPath: file.folderPath || undefined, vfsPath: file.vfsPath, contentType: file.contentType, size: file.size, uploadedAt: file.uploadedAt.toISOString(), readContentWith: file.vfsPath ? `${file.vfsPath}/content` : undefined, note: 'This is file metadata only. To read the file text/bytes, read the readContentWith path (i.e. append /content).', }, null, 2 ) } /** * Serialize table metadata for VFS tables/{name}/meta.json */ export function serializeTableMeta(table: { id: string name: string description?: string | null schema: unknown rowCount: number maxRows: number createdAt: Date | string updatedAt: Date | string }): string { return JSON.stringify( { id: table.id, name: table.name, description: table.description || undefined, schema: table.schema, rowCount: table.rowCount, maxRows: table.maxRows, createdAt: table.createdAt instanceof Date ? table.createdAt.toISOString() : table.createdAt, updatedAt: table.updatedAt instanceof Date ? table.updatedAt.toISOString() : table.updatedAt, }, null, 2 ) } /** * Returns the static model list from PROVIDER_DEFINITIONS for VFS serialization. * Excludes dynamic providers (ollama, vllm, openrouter) whose models are user-configured. * Includes provider ID and whether the model is hosted by Sim (no API key required). */ interface StaticModelOption { id: string provider: string hosted: boolean recommended?: boolean speedOptimized?: boolean deprecated?: boolean } const DYNAMIC_PROVIDERS_NOTE = { note: 'The options array above lists Sim\'s static provider catalog. These providers also accept user-configured models that are NOT enumerated here: the user may have additional ids available at runtime (e.g. local Ollama tags). To reference one, prefix the model id with the provider slash below — for example "ollama/llama3.1:8b" instead of the bare "llama3.1:8b". The server rejects bare ids that are not in the catalog; always use the prefix for user-configured models.', prefixes: DYNAMIC_MODEL_PROVIDERS.map((p) => `${p}/`), } as const function getStaticModelOptionsForVFS(): StaticModelOption[] { const hostedProviders = new Set(['openai', 'anthropic', 'google']) const dynamicProviders = new Set(DYNAMIC_MODEL_PROVIDERS) const models: StaticModelOption[] = [] for (const [providerId, def] of Object.entries(PROVIDER_DEFINITIONS)) { if (dynamicProviders.has(providerId)) continue for (const model of def.models) { const option: StaticModelOption = { id: model.id, provider: providerId, hosted: hostedProviders.has(providerId), } if (model.recommended) option.recommended = true if (model.speedOptimized) option.speedOptimized = true if (model.deprecated) option.deprecated = true models.push(option) } } return models } /** * Serialize a SubBlockConfig for the VFS component schema. * Strips functions and UI-only fields. Includes static options arrays. */ function serializeSubBlock(sb: SubBlockConfig): Record { const result: Record = { id: sb.id, type: sb.type, } if (sb.title) result.title = sb.title if (sb.required === true) result.required = true if (sb.defaultValue !== undefined) result.defaultValue = sb.defaultValue if (sb.mode) result.mode = sb.mode if (sb.canonicalParamId) result.canonicalParamId = sb.canonicalParamId // Include static options arrays for dropdowns if (Array.isArray(sb.options)) { result.options = sb.options } return result } /** * Serialize a block schema for VFS components/blocks/{type}.json */ export function serializeBlockSchema(block: BlockConfig): string { // Custom blocks bake their `workflowId`/`inputMapping` as `hidden` sub-blocks; // treat `hidden` as hidden for them so those never reach the agent's schema. const customBlock = isCustomBlockType(block.type) const hiddenIds = new Set( block.subBlocks .filter((sb) => isSubBlockHidden(sb) || (customBlock && sb.hidden)) .map((sb) => sb.id) ) const subBlocks = block.subBlocks .filter((sb) => !hiddenIds.has(sb.id)) .map((sb) => { const serialized = serializeSubBlock(sb) if (sb.id === 'model' && sb.type === 'combobox' && typeof sb.options === 'function') { serialized.options = getStaticModelOptionsForVFS() serialized.dynamicProviders = DYNAMIC_PROVIDERS_NOTE } return serialized }) const inputs = block.inputs && hiddenIds.size > 0 ? Object.fromEntries(Object.entries(block.inputs).filter(([key]) => !hiddenIds.has(key))) : block.inputs return JSON.stringify( { type: block.type, name: block.name, description: block.description, category: block.category, longDescription: block.longDescription || undefined, bestPractices: block.bestPractices || undefined, triggerAllowed: block.triggerAllowed || undefined, singleInstance: block.singleInstance || undefined, // Custom (deploy-as-block) blocks execute via a baked `workflow_executor` // internally; that's implementation plumbing, not something the agent // configures. Hiding it keeps the block self-contained (fields in, outputs // out) so the agent doesn't treat it like the generic workflow block and // ask for a workflowId/inputMapping. tools: isCustomBlockType(block.type) ? [] : block.tools.access, subBlocks, inputs, outputs: Object.fromEntries( Object.entries(block.outputs) .filter(([key, val]) => key !== 'visualization' && val != null) .map(([key, val]) => [ key, typeof val === 'string' ? { type: val } : { type: val.type, description: (val as { description?: string }).description }, ]) ), }, null, 2 ) } /** * Serialize OAuth credentials for VFS environment/credentials.json. * Shows which integrations are connected — IDs, roles, and scopes, NOT tokens. */ export function serializeCredentials( accounts: Array<{ id?: string providerId: string displayName?: string | null role?: string | null scope: string | null createdAt: Date }> ): string { return JSON.stringify( accounts.map((a) => ({ id: a.id || undefined, provider: a.providerId, displayName: a.displayName || undefined, role: a.role || undefined, scope: a.scope || undefined, connectedAt: a.createdAt.toISOString(), })), null, 2 ) } /** * Serialize API keys for VFS environment/api-keys.json. * Shows key names and types — NOT the actual key values. */ export function serializeApiKeys( keys: Array<{ id: string name: string type: string lastUsed: Date | null createdAt: Date expiresAt: Date | null }> ): string { return JSON.stringify( keys.map((k) => ({ id: k.id, name: k.name, type: k.type, lastUsed: k.lastUsed?.toISOString(), createdAt: k.createdAt.toISOString(), expiresAt: k.expiresAt?.toISOString(), })), null, 2 ) } /** * Serialize environment variables for VFS environment/variables.json. * Shows variable NAMES only — NOT values. */ export function serializeEnvironmentVariables( personalVarNames: string[], workspaceVarNames: string[] ): string { return JSON.stringify( { personal: personalVarNames, workspace: workspaceVarNames, }, null, 2 ) } /** Input types for deployment serialization. */ export interface DeploymentData { workflowId: string isDeployed: boolean deployedAt?: Date | null needsRedeployment?: boolean api?: { version: number createdAt: Date } | null chat?: { id: string identifier: string title: string description?: string | null authType: string customizations: unknown isActive: boolean } | null mcp: Array<{ serverId: string serverName: string toolId: string toolName: string toolDescription?: string | null }> versions?: Array<{ id: string version: number name: string | null description: string | null isActive: boolean createdAt: Date }> } /** * Serialize all deployment configurations for VFS deployment.json. * Only includes keys for active deployment types. */ export function serializeDeployments(data: DeploymentData): string { const result: Record = {} if (data.needsRedeployment !== undefined) { result.needsRedeployment = data.needsRedeployment } if (data.isDeployed) { result.api = { isDeployed: true, deployedAt: data.deployedAt?.toISOString(), apiEndpoint: `/api/workflows/${data.workflowId}/execute`, ...(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.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, })) } return JSON.stringify(result, null, 2) } /** * Serialize deployment version history for VFS workflows/{name}/versions.json. * Lists all versions without full state — use the diff_workflows tool to compare a version, * or load_deployment to restore one into the draft. */ export function serializeVersions( versions: Array<{ id: string version: number name: string | null description: string | null isActive: boolean createdAt: Date }> ): string { return JSON.stringify( versions.map((v) => ({ id: v.id, version: v.version, name: v.name || undefined, description: v.description || undefined, isActive: v.isActive, createdAt: v.createdAt.toISOString(), })), 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: truncate(tool.code, 500), }, null, 2 ) } /** * Serialize an MCP server for VFS agent/mcp-servers/{name}.json */ export function serializeMcpServer(server: { id: string name: string url: string | null transport: string | null enabled: boolean connectionStatus: string | null }): string { return JSON.stringify( { id: server.id, name: server.name, url: server.url, transport: server.transport, enabled: server.enabled, connectionStatus: server.connectionStatus, }, null, 2 ) } /** * Serialize a skill for VFS agent/skills/{name}.json */ export function serializeSkill(s: { id: string name: string description: string content: string createdAt: Date }): string { return JSON.stringify( { id: s.id, name: s.name, description: s.description, contentPreview: truncate(s.content, 500), createdAt: s.createdAt.toISOString(), }, null, 2 ) } /** * Serialize an integration/tool schema for VFS components/integrations/{service}/{operation}.json */ export function serializeIntegrationSchema(tool: ToolConfig): string { const hostedApiKeyParam = isHosted && tool.hosting ? tool.hosting.apiKeyParam : null return JSON.stringify( { // The full registry id is the agent-callable id (deferred tools are sent // with this exact id; no stripping). Surface it verbatim so "copy the id // field and load it" matches the callable tool and the block's tools.access. id: tool.id, name: tool.name, description: getCopilotToolDescription(tool, { isHosted }), version: tool.version, oauth: tool.oauth ? { required: tool.oauth.required, provider: tool.oauth.provider } : undefined, params: tool.params ? { ...Object.fromEntries( Object.entries(tool.params) .filter(([key, val]) => val != null && key !== hostedApiKeyParam) .map(([key, val]) => [ key, { type: val.type, required: val.required, description: val.description, default: val.default, }, ]) ), ...(tool.oauth?.required && { credentialId: { type: 'string', required: false, description: 'Credential ID to use for this OAuth tool call. For Copilot/Superagent execution, pass this explicitly. Get valid IDs from environment/credentials.json.', }, }), } : undefined, outputs: tool.outputs ? Object.fromEntries( Object.entries(tool.outputs) .filter(([, val]) => val != null) .map(([key, val]) => [key, { type: val.type, description: val.description }]) ) : undefined, }, null, 2 ) } /** * Serialize a trigger schema for VFS components/triggers/{provider}/{id}.json */ export function serializeTriggerSchema(trigger: { id: string name: string provider: string description: string version: string subBlocks: SubBlockConfig[] outputs: Record webhook?: { method?: string; headers?: Record } }): string { return JSON.stringify( { id: trigger.id, name: trigger.name, provider: trigger.provider, description: trigger.description, version: trigger.version, webhook: trigger.webhook || undefined, subBlocks: trigger.subBlocks.map(serializeSubBlock), outputs: trigger.outputs, }, null, 2 ) } /** * Serialize a built-in trigger block for VFS components/triggers/sim/{type}.json */ export function serializeBuiltinTriggerSchema(block: BlockConfig): string { return JSON.stringify( { type: block.type, name: block.name, description: block.description, longDescription: block.longDescription || undefined, category: 'builtin', triggers: block.triggers || undefined, subBlocks: block.subBlocks.map(serializeSubBlock), inputs: block.inputs, outputs: block.outputs, }, null, 2 ) } interface TriggerOverviewEntry { id: string name: string provider: string description: string } /** * Serialize a triggers.md overview for VFS components/triggers/triggers.md */ export function serializeTriggerOverview( builtinTriggers: TriggerOverviewEntry[], externalTriggers: TriggerOverviewEntry[] ): string { const lines: string[] = ['# Triggers', ''] lines.push('## Built-in Triggers', '') lines.push('| ID | Name | Description |') lines.push('|----|------|-------------|') for (const t of builtinTriggers) { lines.push(`| ${t.id} | ${t.name} | ${t.description} |`) } lines.push('') lines.push('## External Triggers', '') lines.push('| Provider | ID | Name | Description |') lines.push('|----------|----|------|-------------|') for (const t of externalTriggers) { lines.push(`| ${t.provider} | ${t.id} | ${t.name} | ${t.description} |`) } lines.push('') return lines.join('\n') } /** * Serialize job metadata for VFS jobs/{id}/meta.json */ export function serializeJobMeta(job: { id: string title: string | null prompt: string cronExpression: string | null timezone: string | null status: string lifecycle: string successCondition: string | null maxRuns: number | null runCount: number nextRunAt: Date | null lastRanAt: Date | null sourceTaskName: string | null sourceChatId: string | null createdAt: Date }): string { return JSON.stringify( { id: job.id, title: job.title || undefined, prompt: job.prompt, cronExpression: job.cronExpression || undefined, timezone: job.timezone || 'UTC', status: job.status, lifecycle: job.lifecycle, successCondition: job.successCondition || undefined, maxRuns: job.maxRuns ?? undefined, runCount: job.runCount, nextRunAt: job.nextRunAt?.toISOString(), lastRanAt: job.lastRanAt?.toISOString(), sourceTaskName: job.sourceTaskName || undefined, sourceChatId: job.sourceChatId || undefined, createdAt: job.createdAt.toISOString(), }, null, 2 ) } export function serializeTaskSession(task: { id: string title: string messageCount: number createdAt: Date updatedAt: Date }): string { return [ `# ${task.title}`, '', `- **Chat ID:** ${task.id}`, `- **Created:** ${task.createdAt.toISOString()}`, `- **Updated:** ${task.updatedAt.toISOString()}`, `- **Messages:** ${task.messageCount}`, '', ].join('\n') } export function serializeTaskChat(rawMessages: unknown[]): string { const filtered: { role: string; content: string }[] = [] for (const msg of rawMessages) { if (!msg || typeof msg !== 'object') continue const m = msg as Record const role = m.role as string | undefined if (role !== 'user' && role !== 'assistant') continue let content = '' if (role === 'assistant' && Array.isArray(m.contentBlocks)) { const textParts: string[] = [] for (const block of m.contentBlocks) { if ( block && typeof block === 'object' && (block as any).type === 'text' && (block as any).content ) { textParts.push((block as any).content) } } content = textParts.join('') } if (!content && typeof m.content === 'string') { content = m.content } if (!content) continue filtered.push({ role, content }) } return JSON.stringify(filtered, null, 2) }