import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db, workflow as workflowTable } from '@sim/db' import { createLogger } from '@sim/logger' import { assertFolderMutable, assertWorkflowMutable } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' import { eq } from 'drizzle-orm' import { performCreateWorkspaceApiKey } from '@/lib/api-key/orchestration' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { buildVfsFolderPathMap, decodeVfsPathSegments, encodeVfsPathSegments, } from '@/lib/copilot/vfs/path-utils' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' import { getSocketServerUrl } from '@/lib/core/utils/urls' import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow' import { getExecutionInputForWorkflow, getExecutionStateForWorkflow, getLatestExecutionStateWithExecutionId, } from '@/lib/workflows/executor/execution-state' import { performCreateFolder, performCreateWorkflow, performDeleteFolder, performDeleteWorkflow, performUpdateFolder, performUpdateWorkflow, } from '@/lib/workflows/orchestration' import { loadDeployedWorkflowState, loadWorkflowFromNormalizedTables, saveWorkflowToNormalizedTables, } from '@/lib/workflows/persistence/utils' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { resolveTriggerRunOptions, validateTriggerInput, } from '@/lib/workflows/triggers/run-options' import { listFolders, setWorkflowVariables, verifyFolderWorkspace } from '@/lib/workflows/utils' import type { SerializableExecutionState } from '@/executor/execution/types' import { hasExecutionResult } from '@/executor/utils/errors' import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' import { ensureWorkflowAccess, ensureWorkspaceAccess, getDefaultWorkspaceId } from '../access' function stripBinaryFields(value: unknown): unknown { if (value === null || value === undefined) return value if (typeof value !== 'object') return value if (Array.isArray(value)) return value.map(stripBinaryFields) const out: Record = {} for (const [k, v] of Object.entries(value as Record)) { if (k === 'base64') continue out[k] = stripBinaryFields(v) } return out } function buildExecutionOutput( result: { success: boolean metadata?: { executionId?: string } output?: unknown logs?: unknown[] error?: string }, extra?: Record ): ToolCallResult { return { success: result.success, output: { executionId: result.metadata?.executionId, success: result.success, ...extra, output: stripBinaryFields(result.output), logs: stripBinaryFields(result.logs), }, error: result.success ? undefined : result.error || 'Workflow execution failed', } } function buildExecutionError(error: unknown): ToolCallResult { const message = toError(error).message if (hasExecutionResult(error)) { return buildExecutionOutput({ ...error.executionResult, success: false, error: error.executionResult.error || message, }) } return { success: false, error: message } } async function resolveRunFromBlockSnapshot( workflowId: string, executionId?: string ): Promise< | { executionId: string snapshot: SerializableExecutionState } | undefined > { const sourceExecution = executionId ? { executionId, state: await getExecutionStateForWorkflow(executionId, workflowId), } : await getLatestExecutionStateWithExecutionId(workflowId) if (!sourceExecution?.state) { return undefined } return { executionId: sourceExecution.executionId, snapshot: sourceExecution.state, } } function resolveRunWorkflowInput(params: { workflow_input?: unknown; input?: unknown }): unknown { if (Object.hasOwn(params, 'workflow_input')) { return params.workflow_input } if (Object.hasOwn(params, 'input')) { return params.input } return undefined } function resolveRunTriggerBlockId(params: { triggerBlockId?: unknown }): string | undefined { return typeof params.triggerBlockId === 'string' && params.triggerBlockId.trim().length > 0 ? params.triggerBlockId : undefined } interface PreparedTriggerRun { triggerBlockId: string input: unknown } /** * Resolves which trigger a copilot run targets and validates the input against * it. There are no fallbacks: an invalid trigger id, an ambiguous workflow, or * input that doesn't match the trigger's schema returns an error string so the * agent fixes it and retries. The resolved triggerBlockId is returned so the * caller pins the executed entry to the validated one. */ async function resolveValidatedTriggerRun( workflowId: string, useDraftState: boolean, params: { triggerBlockId?: unknown workflow_input?: unknown input?: unknown useMockPayload?: unknown inputFromExecutionId?: unknown } ): Promise { const state = useDraftState ? await loadWorkflowFromNormalizedTables(workflowId) : await loadDeployedWorkflowState(workflowId) if (!state?.blocks) { return { error: `Workflow ${workflowId} has no ${useDraftState ? 'saved draft' : 'deployed'} state to run.`, } } const merged = mergeSubblockStateWithValues(state.blocks) const options = resolveTriggerRunOptions(merged, state.edges) if (options.length === 0) { return { error: 'No runnable trigger found. Add a Start/API/Input/Chat trigger or an external (webhook/integration) trigger before running.', } } const listTriggers = () => options.map((option) => `${option.triggerBlockId} (${option.blockName})`).join(', ') const requestedId = resolveRunTriggerBlockId(params) let option = options[0] if (requestedId) { const match = options.find((o) => o.triggerBlockId === requestedId) if (!match) { return { error: `triggerBlockId "${requestedId}" is not a runnable trigger in this workflow. Valid triggers: ${listTriggers()}. Call get_workflow_run_options to inspect them.`, } } option = match } else if (options.length > 1) { return { error: `This workflow has multiple triggers — pass triggerBlockId to choose one: ${listTriggers()}. Call get_workflow_run_options for each trigger's input shape.`, } } const providedInput = resolveRunWorkflowInput(params) const hasProvidedInput = providedInput !== undefined const useMock = params.useMockPayload === true const fromExecutionId = typeof params.inputFromExecutionId === 'string' && params.inputFromExecutionId.trim().length > 0 ? params.inputFromExecutionId.trim() : undefined const sourceCount = (hasProvidedInput ? 1 : 0) + (useMock ? 1 : 0) + (fromExecutionId ? 1 : 0) if (sourceCount > 1) { return { error: 'Provide only one input source: workflow_input, useMockPayload: true, or inputFromExecutionId.', } } // Mock payload is generated to match the trigger, so it bypasses validation. if (useMock) { return { triggerBlockId: option.triggerBlockId, input: option.mockPayload } } let inputToValidate = providedInput if (fromExecutionId) { const past = await getExecutionInputForWorkflow(fromExecutionId, workflowId) if (!past.found) { return { error: `No execution "${fromExecutionId}" found for this workflow to reuse input from.`, } } if (past.input === undefined) { return { error: `Execution "${fromExecutionId}" has no recorded input to reuse.` } } inputToValidate = past.input } const validation = validateTriggerInput(option, inputToValidate) if (!validation.ok) { return { error: validation.error || 'workflow_input is invalid for the target trigger.' } } return { triggerBlockId: option.triggerBlockId, input: inputToValidate } } function isBlockProtected(blockId: string, blocksById: Record): boolean { const block = blocksById[blockId] if (!block) return false if (block.locked) return true const visited = new Set() let parentId = block.data?.parentId while (parentId && !visited.has(parentId)) { visited.add(parentId) if (blocksById[parentId]?.locked) return true parentId = blocksById[parentId]?.data?.parentId } return false } function hasDisabledAncestor(blockId: string, blocksById: Record): boolean { const visited = new Set() let parentId = blocksById[blockId]?.data?.parentId while (parentId && !visited.has(parentId)) { visited.add(parentId) const parent = blocksById[parentId] if (!parent) return false if (parent.enabled === false) return true parentId = parent.data?.parentId } return false } function findDescendants(containerId: string, blocksById: Record): string[] { const descendants: string[] = [] const stack = [containerId] const visited = new Set() while (stack.length > 0) { const current = stack.pop()! if (visited.has(current)) continue visited.add(current) for (const [blockId, block] of Object.entries(blocksById)) { if (block.data?.parentId === current) { descendants.push(blockId) stack.push(blockId) } } } return descendants } function notifyWorkflowUpdated(workflowId: string): void { fetch(`${getSocketServerUrl()}/api/workflow-updated`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET, }, body: JSON.stringify({ workflowId }), }).catch((error) => { logger.warn('Failed to notify socket server of workflow update', { workflowId, error }) }) } import type { CreateFolderParams, CreateWorkflowParams, DeleteFolderParams, DeleteWorkflowParams, GenerateApiKeyParams, ManageFolderParams, MoveFolderParams, MoveWorkflowParams, RenameFolderParams, RenameWorkflowParams, RunBlockParams, RunFromBlockParams, RunWorkflowParams, RunWorkflowUntilBlockParams, SetBlockEnabledParams, SetGlobalWorkflowVariablesParams, UpdateWorkflowParams, VariableOperation, } from '../param-types' const logger = createLogger('WorkflowMutations') function assertWorkflowMutationNotAborted( context: ExecutionContext, message = 'Request aborted before workflow mutation could be applied.' ): void { if (context.abortSignal?.aborted) { throw new Error(message) } } export async function executeCreateWorkflow( params: CreateWorkflowParams, context: ExecutionContext ): Promise { try { 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: 'Workflow name must be 200 characters or less' } } const description = typeof params?.description === 'string' ? params.description : null if (description && description.length > 2000) { return { success: false, error: 'Description must be 2000 characters or less' } } const workspaceId = params?.workspaceId || context.workspaceId || (await getDefaultWorkspaceId(context.userId)) const folderId = params?.folderId || null await ensureWorkspaceAccess(workspaceId, context.userId, 'write') await assertFolderMutable(folderId) assertWorkflowMutationNotAborted(context) const result = await performCreateWorkflow({ userId: context.userId, workspaceId, name, description, folderId, }) if (!result.success || !result.workflow) { return { success: false, error: result.error || 'Failed to create workflow' } } try { const { PlatformEvents } = await import('@/lib/core/telemetry') PlatformEvents.workflowCreated({ workflowId: result.workflow.id, name: result.workflow.name, workspaceId, folderId: folderId ?? undefined, }) } catch (_e) { // Telemetry is best-effort } const normalized = await loadWorkflowFromNormalizedTables(result.workflow.id) let copilotSanitizedWorkflowState: unknown if (normalized) { copilotSanitizedWorkflowState = sanitizeForCopilot({ blocks: normalized.blocks || {}, edges: normalized.edges || [], loops: normalized.loops || {}, parallels: normalized.parallels || {}, } as WorkflowState) } return { success: true, output: { workflowId: result.workflow.id, workflowName: result.workflow.name, workspaceId: result.workflow.workspaceId, folderId: result.workflow.folderId, ...(copilotSanitizedWorkflowState ? { copilotSanitizedWorkflowState } : {}), }, } } catch (error) { return { success: false, error: toError(error).message } } } export async function executeCreateFolder( params: CreateFolderParams, context: ExecutionContext ): Promise { try { 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' } } const workspaceId = params?.workspaceId || context.workspaceId || (await getDefaultWorkspaceId(context.userId)) const parentId = params?.parentId || null await ensureWorkspaceAccess(workspaceId, context.userId, 'write') await assertFolderMutable(parentId) assertWorkflowMutationNotAborted(context) const result = await performCreateFolder({ userId: context.userId, workspaceId, name, parentId, }) if (!result.success || !result.folder) { return { success: false, error: result.error || 'Failed to create folder' } } return { success: true, output: { folderId: result.folder.id, name: result.folder.name, workspaceId: result.folder.workspaceId, parentId: result.folder.parentId, }, } } catch (error) { return { success: false, error: toError(error).message } } } export async function executeRunWorkflow( params: RunWorkflowParams, context: ExecutionContext ): Promise { try { const workflowId = params.workflowId || context.workflowId if (!workflowId) { return { success: false, error: 'workflowId is required' } } const { workflow: workflowRecord } = await ensureWorkflowAccess( workflowId, context.userId, 'write' ) const useDraftState = !params.useDeployedState const prepared = await resolveValidatedTriggerRun(workflowId, useDraftState, params) if ('error' in prepared) { return { success: false, error: prepared.error } } const result = await executeWorkflow( { id: workflowRecord.id, userId: workflowRecord.userId, workspaceId: workflowRecord.workspaceId, variables: workflowRecord.variables || {}, }, generateRequestId(), prepared.input, context.userId, { enabled: true, useDraftState, workflowTriggerType: 'copilot', triggerBlockId: prepared.triggerBlockId, } ) return buildExecutionOutput(result) } catch (error) { return buildExecutionError(error) } } export async function executeSetGlobalWorkflowVariables( params: SetGlobalWorkflowVariablesParams, context: ExecutionContext ): Promise { try { const workflowId = params.workflowId || context.workflowId if (!workflowId) { return { success: false, error: 'workflowId is required' } } const operations: VariableOperation[] = Array.isArray(params.operations) ? params.operations : [] const { workflow: workflowRecord } = await ensureWorkflowAccess( workflowId, context.userId, 'write' ) await assertWorkflowMutable(workflowId) interface WorkflowVariable { id: string workflowId?: string name: string type: string value?: unknown } const currentVarsRecord = (workflowRecord.variables as Record) || {} const byName: Record = {} Object.values(currentVarsRecord).forEach((v) => { if (v && typeof v === 'object' && 'id' in v && 'name' in v) { const variable = v as WorkflowVariable byName[String(variable.name)] = variable } }) for (const op of operations) { const key = String(op?.name || '') if (!key) continue const nextType = op?.type || byName[key]?.type || 'plain' const coerceValue = (value: unknown, type: string): unknown => { if (value === undefined) return value if (type === 'number') { const n = Number(value) return Number.isNaN(n) ? value : n } if (type === 'boolean') { const v = String(value).trim().toLowerCase() if (v === 'true') return true if (v === 'false') return false return value } if (type === 'array' || type === 'object') { try { const parsed = JSON.parse(String(value)) if (type === 'array' && Array.isArray(parsed)) return parsed if (type === 'object' && parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed } catch (error) { logger.warn('Failed to parse JSON value for variable coercion', { error: toError(error).message, }) } return value } return value } if (op.operation === 'delete') { delete byName[key] continue } const typedValue = coerceValue(op.value, nextType) if (op.operation === 'add') { byName[key] = { id: generateId(), workflowId, name: key, type: nextType, value: typedValue, } continue } if (op.operation === 'edit') { if (!byName[key]) { byName[key] = { id: generateId(), workflowId, name: key, type: nextType, value: typedValue, } } else { byName[key] = { ...byName[key], type: nextType, value: typedValue, } } } } const nextVarsRecord = Object.fromEntries(Object.values(byName).map((v) => [String(v.id), v])) assertWorkflowMutationNotAborted(context) await setWorkflowVariables(workflowId, nextVarsRecord) notifyWorkflowUpdated(workflowId) recordAudit({ actorId: context.userId, action: AuditAction.WORKFLOW_VARIABLES_UPDATED, resourceType: AuditResourceType.WORKFLOW, resourceId: workflowId, description: `Updated workflow variables`, metadata: { operationCount: operations.length, source: 'copilot' }, }) return { success: true, output: { updated: Object.values(byName).length } } } catch (error) { return { success: false, error: toError(error).message } } } export async function executeRenameWorkflow( params: RenameWorkflowParams, context: ExecutionContext ): Promise { try { const workflowId = params.workflowId if (!workflowId) { return { success: false, error: 'workflowId 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: 'Workflow name must be 200 characters or less' } } const current = await ensureWorkflowAccess(workflowId, context.userId, 'write') await assertWorkflowMutable(workflowId) assertWorkflowMutationNotAborted(context) if (!current.workspaceId) { return { success: false, error: 'Workflow workspace is required' } } const result = await performUpdateWorkflow({ workflowId, userId: context.userId, workspaceId: current.workspaceId, currentName: current.workflow.name, currentFolderId: current.workflow.folderId, name, }) if (!result.success) { return { success: false, error: result.error || 'Failed to rename workflow' } } return { success: true, output: { workflowId, name } } } catch (error) { return { success: false, error: toError(error).message } } } export async function executeMoveWorkflow( params: MoveWorkflowParams, context: ExecutionContext ): Promise { try { const workflowIds = params.workflowIds if (!workflowIds || workflowIds.length === 0) { return { success: false, error: 'workflowIds is required' } } const folderId = params.folderId || null const moved: string[] = [] const failed: string[] = [] await assertFolderMutable(folderId) for (const workflowId of workflowIds) { try { const { workspaceId, workflow } = await ensureWorkflowAccess( workflowId, context.userId, 'write' ) if (!workspaceId) { failed.push(workflowId) continue } if (folderId) { if (!workspaceId || !(await verifyFolderWorkspace(folderId, workspaceId))) { failed.push(workflowId) continue } } await assertWorkflowMutable(workflowId) assertWorkflowMutationNotAborted(context) const result = await performUpdateWorkflow({ workflowId, userId: context.userId, workspaceId, currentName: workflow.name, currentFolderId: workflow.folderId, folderId, }) if (!result.success) { failed.push(workflowId) continue } moved.push(workflowId) } catch { failed.push(workflowId) } } return { success: moved.length > 0, output: { moved, failed, folderId } } } catch (error) { return { success: false, error: toError(error).message } } } export async function executeMoveFolder( params: MoveFolderParams, context: ExecutionContext ): Promise { try { const folderId = params.folderId if (!folderId) { return { success: false, error: 'folderId is required' } } const parentId = params.parentId || null const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) await ensureWorkspaceAccess(workspaceId, context.userId, 'write') if (!(await verifyFolderWorkspace(folderId, workspaceId))) { return { success: false, error: 'Folder not found' } } if (parentId && !(await verifyFolderWorkspace(parentId, workspaceId))) { return { success: false, error: 'Parent folder not found' } } await assertFolderMutable(folderId) await assertFolderMutable(parentId) assertWorkflowMutationNotAborted(context) const result = await performUpdateFolder({ folderId, workspaceId, userId: context.userId, parentId, }) if (!result.success) { return { success: false, error: result.error || 'Failed to move folder' } } return { success: true, output: { folderId, parentId } } } catch (error) { return { success: false, error: toError(error).message } } } export async function executeRunWorkflowUntilBlock( params: RunWorkflowUntilBlockParams, context: ExecutionContext ): Promise { try { const workflowId = params.workflowId || context.workflowId if (!workflowId) { return { success: false, error: 'workflowId is required' } } if (!params.stopAfterBlockId) { return { success: false, error: 'stopAfterBlockId is required' } } const { workflow: workflowRecord } = await ensureWorkflowAccess( workflowId, context.userId, 'write' ) const useDraftState = !params.useDeployedState const prepared = await resolveValidatedTriggerRun(workflowId, useDraftState, params) if ('error' in prepared) { return { success: false, error: prepared.error } } const result = await executeWorkflow( { id: workflowRecord.id, userId: workflowRecord.userId, workspaceId: workflowRecord.workspaceId, variables: workflowRecord.variables || {}, }, generateRequestId(), prepared.input, context.userId, { enabled: true, useDraftState, stopAfterBlockId: params.stopAfterBlockId, workflowTriggerType: 'copilot', triggerBlockId: prepared.triggerBlockId, } ) return buildExecutionOutput(result, { stoppedAfterBlockId: params.stopAfterBlockId }) } catch (error) { return buildExecutionError(error) } } export async function executeGenerateApiKey( params: GenerateApiKeyParams, context: ExecutionContext ): Promise { try { 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: 'API key name must be 200 characters or less' } } const workspaceId = params.workspaceId || context.workspaceId || (await getDefaultWorkspaceId(context.userId)) await ensureWorkspaceAccess(workspaceId, context.userId, 'admin') assertWorkflowMutationNotAborted(context) const result = await performCreateWorkspaceApiKey({ workspaceId, userId: context.userId, name, source: 'copilot', }) if (!result.success || !result.key) { return { success: false, error: result.error || 'Failed to generate API key' } } return { success: true, output: { id: result.key.id, name: result.key.name, key: result.key.key, workspaceId, message: 'API key created successfully. Copy this key now — it will not be shown again. Use this key in the x-api-key header when calling workflow API endpoints.', }, } } catch (error) { return { success: false, error: toError(error).message } } } export async function executeRunFromBlock( params: RunFromBlockParams, context: ExecutionContext ): Promise { try { const workflowId = params.workflowId || context.workflowId if (!workflowId) { return { success: false, error: 'workflowId is required' } } if (!params.startBlockId) { return { success: false, error: 'startBlockId is required' } } const sourceSnapshot = await resolveRunFromBlockSnapshot(workflowId, params.executionId) if (!sourceSnapshot) { return { success: false, error: params.executionId ? `No execution state found for execution ${params.executionId}. Run the full workflow first.` : `No execution state found for workflow ${workflowId}. Run the full workflow first to create a snapshot.`, } } const { workflow: workflowRecord } = await ensureWorkflowAccess( workflowId, context.userId, 'write' ) const useDraftState = !params.useDeployedState const result = await executeWorkflow( { id: workflowRecord.id, userId: workflowRecord.userId, workspaceId: workflowRecord.workspaceId, variables: workflowRecord.variables || {}, }, generateRequestId(), resolveRunWorkflowInput(params), context.userId, { enabled: true, useDraftState, workflowTriggerType: 'copilot', runFromBlock: { startBlockId: params.startBlockId, sourceSnapshot: sourceSnapshot.snapshot, sourceExecutionId: sourceSnapshot.executionId, }, } ) return buildExecutionOutput(result, { startBlockId: params.startBlockId }) } catch (error) { return buildExecutionError(error) } } async function executeUpdateWorkflow( params: UpdateWorkflowParams, context: ExecutionContext ): Promise { try { const workflowId = params.workflowId if (!workflowId) { return { success: false, error: 'workflowId is required' } } const updates: { name?: string; description?: string } = {} 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 === 0) { return { success: false, error: 'At least one of name or description is required' } } const current = await ensureWorkflowAccess(workflowId, context.userId, 'write') if (!current.workspaceId) { return { success: false, error: 'Workflow workspace is required' } } await assertWorkflowMutable(workflowId) assertWorkflowMutationNotAborted(context) const result = await performUpdateWorkflow({ workflowId, userId: context.userId, workspaceId: current.workspaceId, currentName: current.workflow.name, currentFolderId: current.workflow.folderId, ...updates, }) if (!result.success) { return { success: false, error: result.error || 'Failed to update workflow' } } return { success: true, output: { workflowId, ...updates }, } } catch (error) { return { success: false, error: toError(error).message } } } export async function executeSetBlockEnabled( params: SetBlockEnabledParams, context: ExecutionContext ): Promise { try { const workflowId = params.workflowId || context.workflowId if (!workflowId) { return { success: false, error: 'workflowId is required' } } if (!params.blockId) { return { success: false, error: 'blockId is required' } } if (typeof params.enabled !== 'boolean') { return { success: false, error: 'enabled must be a boolean' } } const { workflow: workflowRecord } = await ensureWorkflowAccess( workflowId, context.userId, 'write' ) await assertWorkflowMutable(workflowId) assertWorkflowMutationNotAborted(context) const normalized = await loadWorkflowFromNormalizedTables(workflowId) if (!normalized) { return { success: false, error: `Workflow ${workflowId} has no normalized state` } } const currentState: WorkflowState = { blocks: normalized.blocks as Record, edges: normalized.edges || [], loops: normalized.loops || {}, parallels: normalized.parallels || {}, lastSaved: Date.now(), } const currentBlocks = currentState.blocks const targetBlock = currentBlocks[params.blockId] if (!targetBlock) { return { success: false, error: `Block ${params.blockId} not found in workflow ${workflowId}`, } } if (isBlockProtected(params.blockId, currentBlocks)) { return { success: false, error: `Block ${params.blockId} is locked or inside a locked container and cannot be updated`, } } if (targetBlock.enabled === params.enabled) { return { success: true, output: { workflowId, workflowName: workflowRecord.name, blockId: params.blockId, enabled: params.enabled, affectedBlockIds: [params.blockId], workflowState: currentState, copilotSanitizedWorkflowState: sanitizeForCopilot(currentState), message: `Block ${params.blockId} is already ${params.enabled ? 'enabled' : 'disabled'}`, }, } } if (params.enabled && hasDisabledAncestor(params.blockId, currentBlocks)) { return { success: false, error: `Cannot enable block ${params.blockId} while one of its parent containers is disabled. Enable the parent first.`, } } const affectedBlockIds = new Set([params.blockId]) if (targetBlock.type === 'loop' || targetBlock.type === 'parallel') { for (const descendantId of findDescendants(params.blockId, currentBlocks)) { if (!isBlockProtected(descendantId, currentBlocks)) { affectedBlockIds.add(descendantId) } } } const nextBlocks: Record = { ...currentBlocks } for (const blockId of affectedBlockIds) { nextBlocks[blockId] = { ...nextBlocks[blockId], enabled: params.enabled, } } const nextState: WorkflowState = { ...currentState, blocks: nextBlocks, lastSaved: Date.now(), } assertWorkflowMutationNotAborted(context) const saveResult = await saveWorkflowToNormalizedTables(workflowId, nextState) if (!saveResult.success) { return { success: false, error: saveResult.error || `Failed to persist enabled state for block ${params.blockId}`, } } await db .update(workflowTable) .set({ lastSynced: new Date(), updatedAt: new Date(), }) .where(eq(workflowTable.id, workflowId)) notifyWorkflowUpdated(workflowId) return { success: true, output: { workflowId, workflowName: workflowRecord.name, blockId: params.blockId, enabled: params.enabled, affectedBlockIds: Array.from(affectedBlockIds), workflowState: nextState, copilotSanitizedWorkflowState: sanitizeForCopilot(nextState), }, } } catch (error) { return { success: false, error: toError(error).message } } } export async function executeDeleteWorkflow( params: DeleteWorkflowParams, context: ExecutionContext ): Promise { try { const workflowIds = params.workflowIds if (!workflowIds || workflowIds.length === 0) { return { success: false, error: 'workflowIds is required' } } const deleted: Array<{ workflowId: string; name: string }> = [] const failed: string[] = [] for (const workflowId of workflowIds) { try { const { workflow: workflowRecord } = await ensureWorkflowAccess( workflowId, context.userId, 'write' ) await assertWorkflowMutable(workflowId) 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: deleted.length > 0, output: { deleted, failed }, } } catch (error) { return { success: false, error: toError(error).message } } } export async function executeDeleteFolder( params: DeleteFolderParams, context: ExecutionContext ): Promise { try { 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, 'write') const folders = await listFolders(workspaceId) 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) try { await assertFolderMutable(folderId) const result = await performDeleteFolder({ folderId, workspaceId, userId: context.userId, folderName: folder.folderName, }) if (result.success) { deleted.push(folderId) } else { failed.push(folderId) } } catch { failed.push(folderId) } } return { success: deleted.length > 0, output: { deleted, failed } } } catch (error) { return { success: false, error: toError(error).message } } } async function executeRenameFolder( params: RenameFolderParams, context: ExecutionContext ): Promise { 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' } } const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) await ensureWorkspaceAccess(workspaceId, context.userId, 'write') if (!(await verifyFolderWorkspace(folderId, workspaceId))) { return { success: false, error: 'Folder not found' } } await assertFolderMutable(folderId) assertWorkflowMutationNotAborted(context) const result = await performUpdateFolder({ folderId, workspaceId, userId: context.userId, name, }) if (!result.success) { return { success: false, error: result.error || 'Failed to rename folder' } } return { success: true, output: { folderId, name } } } catch (error) { return { success: false, error: toError(error).message } } } /** * Strip the `workflows/` VFS prefix from a folder path, returning the * folder-relative remainder. `workflows` (or an empty path) maps to the * workspace root and yields an empty string. */ function workflowFolderRelativePath(rawPath: string): string { const trimmed = rawPath.trim().replace(/^\/+|\/+$/g, '') if (!trimmed || trimmed === 'workflows') return '' return trimmed.startsWith('workflows/') ? trimmed.slice('workflows/'.length) : trimmed } /** * Load a lookup from each folder's canonical encoded VFS path to its id by * inverting the same {@link buildVfsFolderPathMap} the VFS uses to serve folder * paths, so a path the agent sees via glob round-trips to the right id. Fetched * once per manage_folder call and reused across target + parent resolution. */ async function loadFolderPathToIdMap(workspaceId: string): Promise> { const byPath = new Map() for (const [folderId, encodedPath] of buildVfsFolderPathMap( await listFolders(workspaceId) ).entries()) { byPath.set(encodedPath, folderId) } return byPath } function lookupFolderIdByPath(rawPath: string, byPath: Map): string | null { const relative = workflowFolderRelativePath(rawPath) if (!relative) return null return byPath.get(encodeVfsPathSegments(decodeVfsPathSegments(relative))) ?? null } /** Resolve the folder a manage_folder op targets, preferring folderId over path. */ async function resolveManageFolderTarget( params: ManageFolderParams, getFolderPaths: () => Promise> ): Promise<{ folderId: string } | { error: string }> { const directId = typeof params.folderId === 'string' ? params.folderId.trim() : '' if (directId) return { folderId: directId } const path = typeof params.path === 'string' ? params.path.trim() : '' if (!path) return { error: 'Provide the folder path (e.g. "workflows/Marketing") or folderId.' } const folderId = lookupFolderIdByPath(path, await getFolderPaths()) if (!folderId) return { error: `Folder not found at ${path}` } return { folderId } } /** * Resolve the destination parent for move/create. parentId/destinationPath are * optional; their absence (or an explicit root) targets the workspace root * (parentId null). */ async function resolveManageFolderParent( params: ManageFolderParams, getFolderPaths: () => Promise> ): Promise<{ parentId: string | null } | { error: string }> { const directId = typeof params.parentId === 'string' ? params.parentId.trim() : '' if (directId) return { parentId: directId } if (params.parentId === null) return { parentId: null } const dest = typeof params.destinationPath === 'string' ? params.destinationPath.trim() : '' if (!dest || !workflowFolderRelativePath(dest)) return { parentId: null } const parentId = lookupFolderIdByPath(dest, await getFolderPaths()) if (!parentId) return { error: `Destination folder not found at ${dest}` } return { parentId } } /** * Single entry point for folder CRUD (create/rename/move/delete). Resolves the * VFS-path/folderId handles, then delegates to the existing folder handlers so * all DB orchestration (performCreateFolder / performUpdateFolder / * performDeleteFolder) stays in one place. */ export async function executeManageFolder( params: ManageFolderParams, context: ExecutionContext ): Promise { try { const operation = typeof params?.operation === 'string' ? params.operation.trim() : '' const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) // Fetch the workspace folder list at most once, lazily — only when a path // (vs an explicit id) actually needs resolving, and shared across the // target + parent lookups a single move/create performs. let folderPathsPromise: Promise> | undefined const getFolderPaths = () => (folderPathsPromise ??= loadFolderPathToIdMap(workspaceId)) switch (operation) { case 'create': { let name = typeof params.name === 'string' ? params.name.trim() : '' let parentId: string | null = null const path = typeof params.path === 'string' ? params.path.trim() : '' if (!name && path) { const segments = decodeVfsPathSegments(workflowFolderRelativePath(path)) if (segments.length === 0) { return { success: false, error: 'create requires a folder name or path' } } name = segments[segments.length - 1] const parentSegments = segments.slice(0, -1) if (parentSegments.length > 0) { const resolved = lookupFolderIdByPath( encodeVfsPathSegments(parentSegments), await getFolderPaths() ) if (!resolved) { return { success: false, error: `Parent folder not found for ${path}` } } parentId = resolved } } else { const parent = await resolveManageFolderParent(params, getFolderPaths) if ('error' in parent) return { success: false, error: parent.error } parentId = parent.parentId } if (!name) return { success: false, error: 'create requires a folder name or path' } return executeCreateFolder({ name, parentId: parentId ?? undefined, workspaceId }, context) } case 'rename': { const name = typeof params.name === 'string' ? params.name.trim() : '' if (!name) return { success: false, error: 'rename requires a new name' } const target = await resolveManageFolderTarget(params, getFolderPaths) if ('error' in target) return { success: false, error: target.error } return executeRenameFolder({ folderId: target.folderId, name }, context) } case 'move': { const target = await resolveManageFolderTarget(params, getFolderPaths) if ('error' in target) return { success: false, error: target.error } const parent = await resolveManageFolderParent(params, getFolderPaths) if ('error' in parent) return { success: false, error: parent.error } return executeMoveFolder({ folderId: target.folderId, parentId: parent.parentId }, context) } case 'delete': { const target = await resolveManageFolderTarget(params, getFolderPaths) if ('error' in target) return { success: false, error: target.error } return executeDeleteFolder({ folderIds: [target.folderId] }, context) } default: return { success: false, error: `Unknown operation "${operation}". Use create, rename, move, or delete.`, } } } catch (error) { return { success: false, error: toError(error).message } } } export async function executeRunBlock( params: RunBlockParams, context: ExecutionContext ): Promise { try { const workflowId = params.workflowId || context.workflowId if (!workflowId) { return { success: false, error: 'workflowId is required' } } if (!params.blockId) { return { success: false, error: 'blockId is required' } } const sourceSnapshot = await resolveRunFromBlockSnapshot(workflowId, params.executionId) if (!sourceSnapshot) { return { success: false, error: params.executionId ? `No execution state found for execution ${params.executionId}. Run the full workflow first.` : `No execution state found for workflow ${workflowId}. Run the full workflow first to create a snapshot.`, } } const { workflow: workflowRecord } = await ensureWorkflowAccess( workflowId, context.userId, 'write' ) const useDraftState = !params.useDeployedState const result = await executeWorkflow( { id: workflowRecord.id, userId: workflowRecord.userId, workspaceId: workflowRecord.workspaceId, variables: workflowRecord.variables || {}, }, generateRequestId(), resolveRunWorkflowInput(params), context.userId, { enabled: true, useDraftState, workflowTriggerType: 'copilot', runFromBlock: { startBlockId: params.blockId, sourceSnapshot: sourceSnapshot.snapshot, sourceExecutionId: sourceSnapshot.executionId, }, stopAfterBlockId: params.blockId, } ) return buildExecutionOutput(result, { blockId: params.blockId }) } catch (error) { return buildExecutionError(error) } }