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
1456 lines
46 KiB
TypeScript
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)
|
|
}
|
|
}
|