Files
simstudioai--sim/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

1456 lines
46 KiB
TypeScript

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<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
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<string, unknown>
): 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<PreparedTriggerRun | { error: string }> {
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<string, BlockState>): boolean {
const block = blocksById[blockId]
if (!block) return false
if (block.locked) return true
const visited = new Set<string>()
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<string, BlockState>): boolean {
const visited = new Set<string>()
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, BlockState>): string[] {
const descendants: string[] = []
const stack = [containerId]
const visited = new Set<string>()
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<ToolCallResult> {
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<ToolCallResult> {
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<ToolCallResult> {
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<ToolCallResult> {
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<string, unknown>) || {}
const byName: Record<string, WorkflowVariable> = {}
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<ToolCallResult> {
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<ToolCallResult> {
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<ToolCallResult> {
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<ToolCallResult> {
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<ToolCallResult> {
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<ToolCallResult> {
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<ToolCallResult> {
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<ToolCallResult> {
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<string, BlockState>,
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<string>([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<string, BlockState> = { ...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<ToolCallResult> {
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<ToolCallResult> {
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<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' }
}
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<Map<string, string>> {
const byPath = new Map<string, string>()
for (const [folderId, encodedPath] of buildVfsFolderPathMap(
await listFolders(workspaceId)
).entries()) {
byPath.set(encodedPath, folderId)
}
return byPath
}
function lookupFolderIdByPath(rawPath: string, byPath: Map<string, string>): 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<Map<string, string>>
): 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<Map<string, string>>
): 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<ToolCallResult> {
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<Map<string, string>> | 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<ToolCallResult> {
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)
}
}