Add Activepieces integration for workflow automation
- Add Activepieces fork with SmoothSchedule custom piece - Create integrations app with Activepieces service layer - Add embed token endpoint for iframe integration - Create Automations page with embedded workflow builder - Add sidebar visibility fix for embed mode - Add list inactive customers endpoint to Public API - Include SmoothSchedule triggers: event created/updated/cancelled - Include SmoothSchedule actions: create/update/cancel events, list resources/services/customers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
21
activepieces-fork/packages/engine/.eslintrc.json
Normal file
21
activepieces-fork/packages/engine/.eslintrc.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": [
|
||||
"../server/api/.eslintrc.json"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"*.ts",
|
||||
"*.js"
|
||||
],
|
||||
"parserOptions": {
|
||||
"project": [
|
||||
"packages/engine/tsconfig.*?.json"
|
||||
]
|
||||
},
|
||||
"rules": {
|
||||
"no-console": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
11
activepieces-fork/packages/engine/README.md
Normal file
11
activepieces-fork/packages/engine/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# engine
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Building
|
||||
|
||||
Run `nx build engine` to build the library.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test engine` to execute the unit tests via [Jest](https://jestjs.io).
|
||||
23
activepieces-fork/packages/engine/jest.config.ts
Normal file
23
activepieces-fork/packages/engine/jest.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
|
||||
process.env.AP_EXECUTION_MODE = 'UNSANDBOXED'
|
||||
process.env.AP_BASE_CODE_DIRECTORY = 'packages/engine/test/resources/codes'
|
||||
process.env.AP_TEST_MODE = 'true'
|
||||
process.env.AP_DEV_PIECES = 'http,data-mapper,approval,webhook'
|
||||
|
||||
/* eslint-disable */
|
||||
export default {
|
||||
displayName: 'engine',
|
||||
preset: '../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': [
|
||||
'ts-jest',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ["node_modules/(?!string\-replace\-async)"],
|
||||
moduleFileExtensions: ['ts', 'js', 'html', 'node'],
|
||||
coverageDirectory: '../../coverage/packages/engine',
|
||||
};
|
||||
6
activepieces-fork/packages/engine/package.json
Normal file
6
activepieces-fork/packages/engine/package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@activepieces/engine",
|
||||
"version": "0.7.0",
|
||||
"type": "commonjs"
|
||||
}
|
||||
|
||||
85
activepieces-fork/packages/engine/project.json
Normal file
85
activepieces-fork/packages/engine/project.json
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"name": "engine",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "packages/engine/src",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/webpack:webpack",
|
||||
"outputs": [
|
||||
"{options.outputPath}"
|
||||
],
|
||||
"options": {
|
||||
"target": "node",
|
||||
"compiler": "tsc",
|
||||
"outputPath": "dist/packages/engine",
|
||||
"main": "packages/engine/src/main.ts",
|
||||
"tsConfig": "packages/engine/tsconfig.lib.json",
|
||||
"assets": [],
|
||||
"webpackConfig": "packages/engine/webpack.config.js",
|
||||
"babelUpwardRootMode": true,
|
||||
"statsJson": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"optimization": true,
|
||||
"extractLicenses": true,
|
||||
"inspect": false,
|
||||
"statsJson": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nx/js:node",
|
||||
"options": {
|
||||
"buildTarget": "engine:build",
|
||||
"inspect": false
|
||||
}
|
||||
},
|
||||
"publish": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "node tools/scripts/publish.mjs engine {args.ver} {args.tag}"
|
||||
},
|
||||
"dependsOn": [
|
||||
"build"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": [
|
||||
"{options.outputFile}"
|
||||
],
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"packages/engine/**/*.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"build-pieces-for-testing": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"commands": [
|
||||
"npx nx run-many --target=build --projects=pieces-http,pieces-data-mapper,pieces-approval,pieces-webhook,shared"
|
||||
],
|
||||
"parallel": false
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": [
|
||||
"{workspaceRoot}/coverage/{projectRoot}"
|
||||
],
|
||||
"options": {
|
||||
"jestConfig": "packages/engine/jest.config.ts",
|
||||
"silent": true
|
||||
},
|
||||
"dependsOn": [
|
||||
"build-pieces-for-testing",
|
||||
"build"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export type CodeModule = {
|
||||
code(input: unknown): Promise<unknown>
|
||||
}
|
||||
|
||||
export type CodeSandbox = {
|
||||
/**
|
||||
* Executes a {@link CodeModule}.
|
||||
*/
|
||||
runCodeModule(params: RunCodeModuleParams): Promise<unknown>
|
||||
|
||||
/**
|
||||
* Executes a script.
|
||||
*/
|
||||
runScript(params: RunScriptParams): Promise<unknown>
|
||||
}
|
||||
|
||||
type RunCodeModuleParams = {
|
||||
/**
|
||||
* The {@link CodeModule} to execute.
|
||||
*/
|
||||
codeModule: CodeModule
|
||||
|
||||
/**
|
||||
* The inputs that are passed to the {@link CodeModule}.
|
||||
*/
|
||||
inputs: Record<string, unknown>
|
||||
}
|
||||
|
||||
type RunScriptParams = {
|
||||
/**
|
||||
* A serialized script that will be executed in the sandbox.
|
||||
* The script can either be sync or async.
|
||||
*/
|
||||
script: string
|
||||
|
||||
/**
|
||||
* A key-value map of variables available to the script during execution.
|
||||
*/
|
||||
scriptContext: Record<string, unknown>
|
||||
|
||||
/**
|
||||
* A key-value map of functions that are available to the script during execution.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
functions: Record<string, Function>
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { EngineGenericError, ExecutionMode, isNil } from '@activepieces/shared'
|
||||
import { CodeSandbox } from '../../core/code/code-sandbox-common'
|
||||
export const EXECUTION_MODE = (process.env.AP_EXECUTION_MODE as ExecutionMode)
|
||||
|
||||
const loadNoOpCodeSandbox = async (): Promise<CodeSandbox> => {
|
||||
const noOpCodeSandboxModule = await import('./no-op-code-sandbox')
|
||||
return noOpCodeSandboxModule.noOpCodeSandbox
|
||||
}
|
||||
|
||||
const loadV8IsolateSandbox = async (): Promise<CodeSandbox> => {
|
||||
const v8IsolateCodeSandboxModule = await import('./v8-isolate-code-sandbox')
|
||||
return v8IsolateCodeSandboxModule.v8IsolateCodeSandbox
|
||||
}
|
||||
|
||||
const loadCodeSandbox = async (): Promise<CodeSandbox> => {
|
||||
const loaders = {
|
||||
[ExecutionMode.UNSANDBOXED]: loadNoOpCodeSandbox,
|
||||
[ExecutionMode.SANDBOX_PROCESS]: loadNoOpCodeSandbox,
|
||||
[ExecutionMode.SANDBOX_CODE_ONLY]: loadV8IsolateSandbox,
|
||||
[ExecutionMode.SANDBOX_CODE_AND_PROCESS]: loadV8IsolateSandbox,
|
||||
}
|
||||
|
||||
if (isNil(EXECUTION_MODE)) {
|
||||
throw new EngineGenericError('ExecutionModeNotSetError', 'AP_EXECUTION_MODE environment variable is not set')
|
||||
}
|
||||
|
||||
const loader = loaders[EXECUTION_MODE]
|
||||
return loader()
|
||||
}
|
||||
|
||||
let instance: CodeSandbox | null = null
|
||||
|
||||
export const initCodeSandbox = async (): Promise<CodeSandbox> => {
|
||||
if (instance === null) {
|
||||
instance = await loadCodeSandbox()
|
||||
}
|
||||
|
||||
return instance
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { CodeSandbox } from '../../core/code/code-sandbox-common'
|
||||
|
||||
/**
|
||||
* Runs code without a sandbox.
|
||||
*/
|
||||
export const noOpCodeSandbox: CodeSandbox = {
|
||||
async runCodeModule({ codeModule, inputs }) {
|
||||
return codeModule.code(inputs)
|
||||
},
|
||||
|
||||
async runScript({ script, scriptContext, functions }) {
|
||||
const newContext = {
|
||||
...scriptContext,
|
||||
...functions,
|
||||
}
|
||||
const params = Object.keys(newContext)
|
||||
const args = Object.values(newContext)
|
||||
const body = `return (${script})`
|
||||
const fn = Function(...params, body)
|
||||
return fn(...args)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { CodeModule, CodeSandbox } from '../../core/code/code-sandbox-common'
|
||||
|
||||
const ONE_HUNDRED_TWENTY_EIGHT_MEGABYTES = 128
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// Check this https://github.com/laverdet/isolated-vm/issues/258#issuecomment-2134341086
|
||||
let ivmCache: any
|
||||
const getIvm = () => {
|
||||
if (!ivmCache) {
|
||||
ivmCache = require('isolated-vm')
|
||||
}
|
||||
return ivmCache as typeof import('isolated-vm')
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs code in a V8 Isolate sandbox
|
||||
*/
|
||||
export const v8IsolateCodeSandbox: CodeSandbox = {
|
||||
async runCodeModule({ codeModule, inputs }) {
|
||||
const ivm = getIvm()
|
||||
const isolate = new ivm.Isolate({ memoryLimit: ONE_HUNDRED_TWENTY_EIGHT_MEGABYTES })
|
||||
|
||||
try {
|
||||
const isolateContext = await initIsolateContext({
|
||||
isolate,
|
||||
codeContext: {
|
||||
inputs,
|
||||
},
|
||||
})
|
||||
|
||||
const serializedCodeModule = serializeCodeModule(codeModule)
|
||||
|
||||
return await executeIsolate({
|
||||
isolate,
|
||||
isolateContext,
|
||||
code: serializedCodeModule,
|
||||
})
|
||||
}
|
||||
finally {
|
||||
isolate.dispose()
|
||||
}
|
||||
},
|
||||
|
||||
async runScript({ script, scriptContext, functions }) {
|
||||
const ivm = getIvm()
|
||||
const isolate = new ivm.Isolate({ memoryLimit: ONE_HUNDRED_TWENTY_EIGHT_MEGABYTES })
|
||||
|
||||
try {
|
||||
// It is to avoid strucutedClone issue of proxy objects / functions, It will throw cannot be cloned error.
|
||||
const isolateContext = await initIsolateContext({
|
||||
isolate,
|
||||
codeContext: JSON.parse(JSON.stringify(scriptContext)),
|
||||
})
|
||||
|
||||
const serializedFunctions = Object.entries(functions).map(([key, value]) => `const ${key} = ${value.toString()};`).join('\n')
|
||||
const scriptWithFunctions = `${serializedFunctions}\n${script}`
|
||||
|
||||
return await executeIsolate({
|
||||
isolate,
|
||||
isolateContext,
|
||||
code: scriptWithFunctions,
|
||||
})
|
||||
}
|
||||
finally {
|
||||
isolate.dispose()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const initIsolateContext = async ({ isolate, codeContext }: InitContextParams): Promise<any> => {
|
||||
const isolateContext = await isolate.createContext()
|
||||
const ivm = getIvm()
|
||||
for (const [key, value] of Object.entries(codeContext)) {
|
||||
await isolateContext.global.set(key, new ivm.ExternalCopy(value).copyInto())
|
||||
}
|
||||
|
||||
return isolateContext
|
||||
}
|
||||
|
||||
const executeIsolate = async ({ isolate, isolateContext, code }: ExecuteIsolateParams): Promise<unknown> => {
|
||||
const isolateScript = await isolate.compileScript(code)
|
||||
|
||||
const outRef = await isolateScript.run(isolateContext, {
|
||||
reference: true,
|
||||
promise: true,
|
||||
})
|
||||
|
||||
return outRef.copy()
|
||||
}
|
||||
|
||||
const serializeCodeModule = (codeModule: CodeModule): string => {
|
||||
const serializedCodeFunction = Object.keys(codeModule)
|
||||
.reduce((acc, key) =>
|
||||
acc + `const ${key} = ${(codeModule as any)[key].toString()};`,
|
||||
'')
|
||||
|
||||
// replace the exports.function_name with function_name
|
||||
return serializedCodeFunction.replace(/\(0, exports\.(\w+)\)/g, '$1') + 'code(inputs);'
|
||||
}
|
||||
|
||||
type InitContextParams = {
|
||||
isolate: any
|
||||
codeContext: Record<string, unknown>
|
||||
}
|
||||
|
||||
type ExecuteIsolateParams = {
|
||||
isolate: any
|
||||
isolateContext: unknown
|
||||
code: string
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { FlowAction } from '@activepieces/shared'
|
||||
import { EngineConstants } from './context/engine-constants'
|
||||
import { FlowExecutorContext } from './context/flow-execution-context'
|
||||
|
||||
export type ActionHandler<T extends FlowAction> = (request: { action: T, executionState: FlowExecutorContext, constants: EngineConstants }) => Promise<FlowExecutorContext>
|
||||
|
||||
export type BaseExecutor<T extends FlowAction> = {
|
||||
handle(request: {
|
||||
action: T
|
||||
executionState: FlowExecutorContext
|
||||
constants: EngineConstants
|
||||
}): Promise<FlowExecutorContext>
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import path from 'path'
|
||||
import importFresh from '@activepieces/import-fresh-webpack'
|
||||
import { LATEST_CONTEXT_VERSION } from '@activepieces/pieces-framework'
|
||||
import { CodeAction, EngineGenericError, FlowActionType, FlowRunStatus, GenericStepOutput, isNil, StepOutputStatus } from '@activepieces/shared'
|
||||
import { initCodeSandbox } from '../core/code/code-sandbox'
|
||||
import { CodeModule } from '../core/code/code-sandbox-common'
|
||||
import { continueIfFailureHandler, runWithExponentialBackoff } from '../helper/error-handling'
|
||||
import { progressService } from '../services/progress.service'
|
||||
import { utils } from '../utils'
|
||||
import { ActionHandler, BaseExecutor } from './base-executor'
|
||||
|
||||
export const codeExecutor: BaseExecutor<CodeAction> = {
|
||||
async handle({
|
||||
action,
|
||||
executionState,
|
||||
constants,
|
||||
}) {
|
||||
if (executionState.isCompleted({ stepName: action.name })) {
|
||||
return executionState
|
||||
}
|
||||
const resultExecution = await runWithExponentialBackoff(executionState, action, constants, executeAction)
|
||||
return continueIfFailureHandler(resultExecution, action, constants)
|
||||
},
|
||||
}
|
||||
|
||||
const executeAction: ActionHandler<CodeAction> = async ({ action, executionState, constants }) => {
|
||||
const stepStartTime = performance.now()
|
||||
const { censoredInput, resolvedInput } = await constants.getPropsResolver(LATEST_CONTEXT_VERSION).resolve<Record<string, unknown>>({
|
||||
unresolvedInput: action.settings.input,
|
||||
executionState,
|
||||
})
|
||||
|
||||
const stepOutput = GenericStepOutput.create({
|
||||
input: censoredInput,
|
||||
type: FlowActionType.CODE,
|
||||
status: StepOutputStatus.RUNNING,
|
||||
})
|
||||
|
||||
const { data: executionStateResult, error: executionStateError } = await utils.tryCatchAndThrowOnEngineError((async () => {
|
||||
await progressService.sendUpdate({
|
||||
engineConstants: constants,
|
||||
flowExecutorContext: executionState.upsertStep(action.name, stepOutput),
|
||||
})
|
||||
|
||||
if (isNil(constants.runEnvironment)) {
|
||||
throw new EngineGenericError('RunEnvironmentNotSetError', 'Run environment is not set')
|
||||
}
|
||||
|
||||
const artifactPath = path.resolve(`${constants.baseCodeDirectory}/${constants.flowVersionId}/${action.name}/index.js`)
|
||||
const codeModule: CodeModule = await importFresh(artifactPath)
|
||||
const codeSandbox = await initCodeSandbox()
|
||||
|
||||
const output = await codeSandbox.runCodeModule({
|
||||
codeModule,
|
||||
inputs: resolvedInput,
|
||||
})
|
||||
|
||||
return executionState.upsertStep(action.name, stepOutput.setOutput(output).setStatus(StepOutputStatus.SUCCEEDED).setDuration(performance.now() - stepStartTime)).incrementStepsExecuted()
|
||||
}))
|
||||
|
||||
if (executionStateError) {
|
||||
const failedStepOutput = stepOutput
|
||||
.setStatus(StepOutputStatus.FAILED)
|
||||
.setErrorMessage(utils.formatError(executionStateError))
|
||||
.setDuration(performance.now() - stepStartTime)
|
||||
|
||||
return executionState
|
||||
.upsertStep(action.name, failedStepOutput)
|
||||
.setVerdict({ status: FlowRunStatus.FAILED, failedStep: {
|
||||
name: action.name,
|
||||
displayName: action.displayName,
|
||||
message: utils.formatError(executionStateError),
|
||||
} })
|
||||
}
|
||||
|
||||
return executionStateResult
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { ContextVersion } from '@activepieces/pieces-framework'
|
||||
import { DEFAULT_MCP_DATA, EngineGenericError, ExecuteFlowOperation, ExecutePropsOptions, ExecuteToolOperation, ExecuteTriggerOperation, ExecutionType, FlowVersionState, PlatformId, ProgressUpdateType, Project, ProjectId, ResumePayload, RunEnvironment, TriggerHookType } from '@activepieces/shared'
|
||||
import { createPropsResolver, PropsResolver } from '../../variables/props-resolver'
|
||||
|
||||
type RetryConstants = {
|
||||
maxAttempts: number
|
||||
retryExponential: number
|
||||
retryInterval: number
|
||||
}
|
||||
|
||||
type EngineConstantsParams = {
|
||||
flowId: string
|
||||
flowVersionId: string
|
||||
flowVersionState: FlowVersionState
|
||||
triggerPieceName: string
|
||||
flowRunId: string
|
||||
publicApiUrl: string
|
||||
internalApiUrl: string
|
||||
retryConstants: RetryConstants
|
||||
engineToken: string
|
||||
projectId: ProjectId
|
||||
progressUpdateType: ProgressUpdateType
|
||||
serverHandlerId: string | null
|
||||
httpRequestId: string | null
|
||||
resumePayload?: ResumePayload
|
||||
runEnvironment?: RunEnvironment
|
||||
stepNameToTest?: string
|
||||
logsUploadUrl?: string
|
||||
logsFileId?: string
|
||||
timeoutInSeconds: number
|
||||
platformId: PlatformId
|
||||
}
|
||||
|
||||
const DEFAULT_RETRY_CONSTANTS: RetryConstants = {
|
||||
maxAttempts: 4,
|
||||
retryExponential: 2,
|
||||
retryInterval: 2000,
|
||||
}
|
||||
|
||||
const DEFAULT_TRIGGER_EXECUTION = 'execute-trigger'
|
||||
const DEFAULT_EXECUTE_PROPERTY = 'execute-property'
|
||||
|
||||
export class EngineConstants {
|
||||
public static readonly BASE_CODE_DIRECTORY = process.env.AP_BASE_CODE_DIRECTORY ?? './codes'
|
||||
public static readonly INPUT_FILE = './input.json'
|
||||
public static readonly OUTPUT_FILE = './output.json'
|
||||
public static readonly DEV_PIECES = process.env.AP_DEV_PIECES?.split(',') ?? []
|
||||
public static readonly TEST_MODE = process.env.AP_TEST_MODE === 'true'
|
||||
|
||||
public readonly platformId: string
|
||||
public readonly timeoutInSeconds: number
|
||||
public readonly flowId: string
|
||||
public readonly flowVersionId: string
|
||||
public readonly flowVersionState: FlowVersionState
|
||||
public readonly triggerPieceName: string
|
||||
public readonly flowRunId: string
|
||||
public readonly publicApiUrl: string
|
||||
public readonly internalApiUrl: string
|
||||
public readonly retryConstants: RetryConstants
|
||||
public readonly engineToken: string
|
||||
public readonly projectId: ProjectId
|
||||
public readonly progressUpdateType: ProgressUpdateType
|
||||
public readonly serverHandlerId: string | null
|
||||
public readonly httpRequestId: string | null
|
||||
public readonly resumePayload?: ResumePayload
|
||||
public readonly runEnvironment?: RunEnvironment
|
||||
public readonly stepNameToTest?: string
|
||||
public readonly logsUploadUrl?: string
|
||||
public readonly logsFileId?: string
|
||||
private project: Project | null = null
|
||||
|
||||
public get isRunningApTests(): boolean {
|
||||
return EngineConstants.TEST_MODE
|
||||
}
|
||||
|
||||
public get baseCodeDirectory(): string {
|
||||
return EngineConstants.BASE_CODE_DIRECTORY
|
||||
}
|
||||
|
||||
public get devPieces(): string[] {
|
||||
return EngineConstants.DEV_PIECES
|
||||
}
|
||||
|
||||
public constructor(params: EngineConstantsParams) {
|
||||
if (!params.publicApiUrl.endsWith('/api/')) {
|
||||
throw new EngineGenericError('PublicUrlNotEndsWithSlashError', `Public URL must end with a slash, got: ${params.publicApiUrl}`)
|
||||
}
|
||||
if (!params.internalApiUrl.endsWith('/')) {
|
||||
throw new EngineGenericError('InternalApiUrlNotEndsWithSlashError', `Internal API URL must end with a slash, got: ${params.internalApiUrl}`)
|
||||
}
|
||||
|
||||
this.flowId = params.flowId
|
||||
this.flowVersionId = params.flowVersionId
|
||||
this.flowVersionState = params.flowVersionState
|
||||
this.flowRunId = params.flowRunId
|
||||
this.publicApiUrl = params.publicApiUrl
|
||||
this.internalApiUrl = params.internalApiUrl
|
||||
this.retryConstants = params.retryConstants
|
||||
this.triggerPieceName = params.triggerPieceName
|
||||
this.engineToken = params.engineToken
|
||||
this.projectId = params.projectId
|
||||
this.progressUpdateType = params.progressUpdateType
|
||||
this.serverHandlerId = params.serverHandlerId
|
||||
this.httpRequestId = params.httpRequestId
|
||||
this.resumePayload = params.resumePayload
|
||||
this.runEnvironment = params.runEnvironment
|
||||
this.stepNameToTest = params.stepNameToTest
|
||||
this.logsUploadUrl = params.logsUploadUrl
|
||||
this.logsFileId = params.logsFileId
|
||||
this.platformId = params.platformId
|
||||
this.timeoutInSeconds = params.timeoutInSeconds
|
||||
}
|
||||
|
||||
public static fromExecuteFlowInput(input: ExecuteFlowOperation): EngineConstants {
|
||||
return new EngineConstants({
|
||||
flowId: input.flowVersion.flowId,
|
||||
flowVersionId: input.flowVersion.id,
|
||||
flowVersionState: input.flowVersion.state,
|
||||
triggerPieceName: input.flowVersion.trigger.settings.pieceName,
|
||||
flowRunId: input.flowRunId,
|
||||
publicApiUrl: input.publicApiUrl,
|
||||
internalApiUrl: input.internalApiUrl,
|
||||
retryConstants: DEFAULT_RETRY_CONSTANTS,
|
||||
engineToken: input.engineToken,
|
||||
projectId: input.projectId,
|
||||
progressUpdateType: input.progressUpdateType,
|
||||
serverHandlerId: input.serverHandlerId ?? null,
|
||||
httpRequestId: input.httpRequestId ?? null,
|
||||
resumePayload: input.executionType === ExecutionType.RESUME ? input.resumePayload : undefined,
|
||||
runEnvironment: input.runEnvironment,
|
||||
stepNameToTest: input.stepNameToTest ?? undefined,
|
||||
logsUploadUrl: input.logsUploadUrl,
|
||||
logsFileId: input.logsFileId,
|
||||
timeoutInSeconds: input.timeoutInSeconds,
|
||||
platformId: input.platformId,
|
||||
})
|
||||
}
|
||||
|
||||
public static fromExecuteActionInput(input: ExecuteToolOperation): EngineConstants {
|
||||
return new EngineConstants({
|
||||
flowId: DEFAULT_MCP_DATA.flowId,
|
||||
flowVersionId: DEFAULT_MCP_DATA.flowVersionId,
|
||||
flowVersionState: DEFAULT_MCP_DATA.flowVersionState,
|
||||
triggerPieceName: DEFAULT_MCP_DATA.triggerPieceName,
|
||||
flowRunId: DEFAULT_MCP_DATA.flowRunId,
|
||||
publicApiUrl: input.publicApiUrl,
|
||||
internalApiUrl: addTrailingSlashIfMissing(input.internalApiUrl),
|
||||
retryConstants: DEFAULT_RETRY_CONSTANTS,
|
||||
engineToken: input.engineToken,
|
||||
projectId: input.projectId,
|
||||
progressUpdateType: ProgressUpdateType.NONE,
|
||||
serverHandlerId: null,
|
||||
httpRequestId: null,
|
||||
resumePayload: undefined,
|
||||
runEnvironment: undefined,
|
||||
stepNameToTest: undefined,
|
||||
timeoutInSeconds: input.timeoutInSeconds,
|
||||
platformId: input.platformId,
|
||||
})
|
||||
}
|
||||
|
||||
public static fromExecutePropertyInput(input: Omit<ExecutePropsOptions, 'piece'> & { pieceName: string, pieceVersion: string }): EngineConstants {
|
||||
return new EngineConstants({
|
||||
flowId: input.flowVersion?.flowId ?? DEFAULT_MCP_DATA.flowId,
|
||||
flowVersionId: input.flowVersion?.id ?? DEFAULT_MCP_DATA.flowVersionId,
|
||||
flowVersionState: input.flowVersion?.state ?? DEFAULT_MCP_DATA.flowVersionState,
|
||||
triggerPieceName: input.flowVersion?.trigger?.settings.pieceName ?? DEFAULT_MCP_DATA.triggerPieceName,
|
||||
flowRunId: DEFAULT_EXECUTE_PROPERTY,
|
||||
publicApiUrl: input.publicApiUrl,
|
||||
internalApiUrl: addTrailingSlashIfMissing(input.internalApiUrl),
|
||||
retryConstants: DEFAULT_RETRY_CONSTANTS,
|
||||
engineToken: input.engineToken,
|
||||
projectId: input.projectId,
|
||||
progressUpdateType: ProgressUpdateType.NONE,
|
||||
serverHandlerId: null,
|
||||
httpRequestId: null,
|
||||
resumePayload: undefined,
|
||||
runEnvironment: undefined,
|
||||
stepNameToTest: undefined,
|
||||
timeoutInSeconds: input.timeoutInSeconds,
|
||||
platformId: input.platformId,
|
||||
})
|
||||
}
|
||||
|
||||
public static fromExecuteTriggerInput(input: ExecuteTriggerOperation<TriggerHookType>): EngineConstants {
|
||||
return new EngineConstants({
|
||||
flowId: input.flowVersion.flowId,
|
||||
flowVersionId: input.flowVersion.id,
|
||||
flowVersionState: input.flowVersion.state,
|
||||
triggerPieceName: input.flowVersion.trigger.settings.pieceName,
|
||||
flowRunId: DEFAULT_TRIGGER_EXECUTION,
|
||||
publicApiUrl: input.publicApiUrl,
|
||||
internalApiUrl: addTrailingSlashIfMissing(input.internalApiUrl),
|
||||
retryConstants: DEFAULT_RETRY_CONSTANTS,
|
||||
engineToken: input.engineToken,
|
||||
projectId: input.projectId,
|
||||
progressUpdateType: ProgressUpdateType.NONE,
|
||||
serverHandlerId: null,
|
||||
httpRequestId: null,
|
||||
resumePayload: undefined,
|
||||
runEnvironment: undefined,
|
||||
stepNameToTest: undefined,
|
||||
timeoutInSeconds: input.timeoutInSeconds,
|
||||
platformId: input.platformId,
|
||||
})
|
||||
}
|
||||
public getPropsResolver(contextVersion: ContextVersion | undefined): PropsResolver {
|
||||
return createPropsResolver({
|
||||
projectId: this.projectId,
|
||||
engineToken: this.engineToken,
|
||||
apiUrl: this.internalApiUrl,
|
||||
contextVersion,
|
||||
})
|
||||
}
|
||||
private async getProject(): Promise<Project> {
|
||||
if (this.project) {
|
||||
return this.project
|
||||
}
|
||||
|
||||
const getWorkerProjectEndpoint = `${this.internalApiUrl}v1/worker/project`
|
||||
|
||||
const response = await fetch(getWorkerProjectEndpoint, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.engineToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
this.project = await response.json() as Project
|
||||
return this.project
|
||||
}
|
||||
|
||||
public externalProjectId = async (): Promise<string | undefined> => {
|
||||
const project = await this.getProject()
|
||||
return project.externalId
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const addTrailingSlashIfMissing = (url: string): string => {
|
||||
return url.endsWith('/') ? url : url + '/'
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { assertEqual, EngineGenericError, FailedStep, FlowActionType, FlowRunStatus, GenericStepOutput, isNil, LoopStepOutput, LoopStepResult, PauseMetadata, PauseType, RespondResponse, StepOutput, StepOutputStatus } from '@activepieces/shared'
|
||||
import dayjs from 'dayjs'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { loggingUtils } from '../../helper/logging-utils'
|
||||
import { StepExecutionPath } from './step-execution-path'
|
||||
|
||||
|
||||
export type FlowVerdict = {
|
||||
status: FlowRunStatus.PAUSED
|
||||
pauseMetadata: PauseMetadata
|
||||
} | {
|
||||
status: FlowRunStatus.SUCCEEDED
|
||||
stopResponse: RespondResponse | undefined
|
||||
} | {
|
||||
status: FlowRunStatus.FAILED
|
||||
failedStep: FailedStep
|
||||
} | {
|
||||
status: FlowRunStatus.RUNNING
|
||||
}
|
||||
|
||||
export class FlowExecutorContext {
|
||||
tags: readonly string[]
|
||||
steps: Readonly<Record<string, StepOutput>>
|
||||
pauseRequestId: string
|
||||
verdict: FlowVerdict
|
||||
currentPath: StepExecutionPath
|
||||
stepNameToTest?: boolean
|
||||
stepsCount: number
|
||||
|
||||
/**
|
||||
* Execution time in milliseconds
|
||||
*/
|
||||
duration: number
|
||||
|
||||
constructor(copyFrom?: FlowExecutorContext) {
|
||||
this.tags = copyFrom?.tags ?? []
|
||||
this.steps = copyFrom?.steps ?? {}
|
||||
this.pauseRequestId = copyFrom?.pauseRequestId ?? nanoid()
|
||||
this.duration = copyFrom?.duration ?? -1
|
||||
this.verdict = copyFrom?.verdict ?? { status: FlowRunStatus.RUNNING }
|
||||
this.currentPath = copyFrom?.currentPath ?? StepExecutionPath.empty()
|
||||
this.stepNameToTest = copyFrom?.stepNameToTest ?? false
|
||||
this.stepsCount = copyFrom?.stepsCount ?? 0
|
||||
}
|
||||
|
||||
static empty(): FlowExecutorContext {
|
||||
return new FlowExecutorContext()
|
||||
}
|
||||
|
||||
public setPauseRequestId(pauseRequestId: string): FlowExecutorContext {
|
||||
return new FlowExecutorContext({
|
||||
...this,
|
||||
pauseRequestId,
|
||||
})
|
||||
}
|
||||
|
||||
public getDelayedInSeconds(): number | undefined {
|
||||
if (this.verdict.status === FlowRunStatus.PAUSED && this.verdict.pauseMetadata.type === PauseType.DELAY) {
|
||||
return dayjs(this.verdict.pauseMetadata.resumeDateTime).diff(Date.now(), 'seconds')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
public finishExecution(): FlowExecutorContext {
|
||||
if (this.verdict.status === FlowRunStatus.RUNNING) {
|
||||
return new FlowExecutorContext({
|
||||
...this,
|
||||
verdict: { status: FlowRunStatus.SUCCEEDED },
|
||||
})
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
public trimmedSteps(): Promise<Record<string, StepOutput>> {
|
||||
return loggingUtils.trimExecution(this.steps)
|
||||
}
|
||||
|
||||
|
||||
public getLoopStepOutput({ stepName }: { stepName: string }): LoopStepOutput | undefined {
|
||||
const stateAtPath = getStateAtPath({ currentPath: this.currentPath, steps: this.steps })
|
||||
const stepOutput = stateAtPath[stepName]
|
||||
if (isNil(stepOutput)) {
|
||||
return undefined
|
||||
}
|
||||
assertEqual(stepOutput.type, FlowActionType.LOOP_ON_ITEMS, 'stepOutput.type', 'LOOP_ON_ITEMS')
|
||||
// The new LoopStepOutput is needed as casting directly to LoopClassOutput will just cast the data but the class methods will not be available
|
||||
return new LoopStepOutput(stepOutput as GenericStepOutput<FlowActionType.LOOP_ON_ITEMS, LoopStepResult>)
|
||||
}
|
||||
|
||||
public isCompleted({ stepName }: { stepName: string }): boolean {
|
||||
const stateAtPath = getStateAtPath({ currentPath: this.currentPath, steps: this.steps })
|
||||
const stepOutput = stateAtPath[stepName]
|
||||
if (isNil(stepOutput)) {
|
||||
return false
|
||||
}
|
||||
return stepOutput.status !== StepOutputStatus.PAUSED
|
||||
}
|
||||
|
||||
public isPaused({ stepName }: { stepName: string }): boolean {
|
||||
const stateAtPath = getStateAtPath({ currentPath: this.currentPath, steps: this.steps })
|
||||
const stepOutput = stateAtPath[stepName]
|
||||
if (isNil(stepOutput)) {
|
||||
return false
|
||||
}
|
||||
return stepOutput.status === StepOutputStatus.PAUSED
|
||||
}
|
||||
|
||||
public setDuration(duration: number): FlowExecutorContext {
|
||||
return new FlowExecutorContext({
|
||||
...this,
|
||||
duration,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
public addTags(tags: string[]): FlowExecutorContext {
|
||||
return new FlowExecutorContext({
|
||||
...this,
|
||||
tags: [...this.tags, ...tags].filter((value, index, self) => {
|
||||
return self.indexOf(value) === index
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
public upsertStep(stepName: string, stepOutput: StepOutput): FlowExecutorContext {
|
||||
const steps = {
|
||||
...this.steps,
|
||||
}
|
||||
const targetMap = getStateAtPath({ currentPath: this.currentPath, steps })
|
||||
targetMap[stepName] = stepOutput
|
||||
|
||||
return new FlowExecutorContext({
|
||||
...this,
|
||||
steps,
|
||||
})
|
||||
}
|
||||
|
||||
public getStepOutput(stepName: string): StepOutput | undefined {
|
||||
const stateAtPath = getStateAtPath({ currentPath: this.currentPath, steps: this.steps })
|
||||
return stateAtPath[stepName]
|
||||
}
|
||||
|
||||
|
||||
|
||||
public setCurrentPath(currentStatePath: StepExecutionPath): FlowExecutorContext {
|
||||
return new FlowExecutorContext({
|
||||
...this,
|
||||
currentPath: currentStatePath,
|
||||
})
|
||||
}
|
||||
|
||||
public setVerdict(verdict: FlowVerdict): FlowExecutorContext {
|
||||
return new FlowExecutorContext({
|
||||
...this,
|
||||
verdict,
|
||||
})
|
||||
}
|
||||
|
||||
public setRetryable(retryable: boolean): FlowExecutorContext {
|
||||
return new FlowExecutorContext({
|
||||
...this,
|
||||
retryable,
|
||||
})
|
||||
}
|
||||
|
||||
public incrementStepsExecuted(): FlowExecutorContext {
|
||||
return new FlowExecutorContext({
|
||||
...this,
|
||||
stepsCount: this.stepsCount + 1,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
public currentState(): Record<string, unknown> {
|
||||
let flattenedSteps: Record<string, unknown> = extractOutput(this.steps)
|
||||
let targetMap = this.steps
|
||||
this.currentPath.path.forEach(([stepName, iteration]) => {
|
||||
const stepOutput = targetMap[stepName]
|
||||
if (!stepOutput.output || stepOutput.type !== FlowActionType.LOOP_ON_ITEMS) {
|
||||
throw new EngineGenericError('NotInstanceOfLoopOnItemsStepOutputError', '[ExecutionState#getTargetMap] Not instance of Loop On Items step output')
|
||||
}
|
||||
targetMap = stepOutput.output.iterations[iteration]
|
||||
flattenedSteps = {
|
||||
...flattenedSteps,
|
||||
...extractOutput(targetMap),
|
||||
}
|
||||
})
|
||||
return flattenedSteps
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
function extractOutput(steps: Record<string, StepOutput>): Record<string, unknown> {
|
||||
return Object.entries(steps).reduce((acc: Record<string, unknown>, [stepName, step]) => {
|
||||
acc[stepName] = step.output
|
||||
return acc
|
||||
}, {} as Record<string, unknown>)
|
||||
}
|
||||
|
||||
function getStateAtPath({ currentPath, steps }: { currentPath: StepExecutionPath, steps: Record<string, StepOutput> }): Record<string, StepOutput> {
|
||||
let targetMap = steps
|
||||
currentPath.path.forEach(([stepName, iteration]) => {
|
||||
const stepOutput = targetMap[stepName]
|
||||
if (!stepOutput.output || stepOutput.type !== FlowActionType.LOOP_ON_ITEMS) {
|
||||
throw new EngineGenericError('NotInstanceOfLoopOnItemsStepOutputError', `[ExecutionState#getTargetMap] Not instance of Loop On Items step output: ${stepOutput.type}`)
|
||||
}
|
||||
targetMap = stepOutput.output.iterations[iteration]
|
||||
})
|
||||
return targetMap
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export class StepExecutionPath {
|
||||
public path: readonly [string, number][] = []
|
||||
|
||||
constructor(path: readonly [string, number][]) {
|
||||
this.path = [...path]
|
||||
}
|
||||
|
||||
loopIteration({ loopName, iteration }: { loopName: string, iteration: number }): StepExecutionPath {
|
||||
return new StepExecutionPath([...this.path, [loopName, iteration]])
|
||||
}
|
||||
|
||||
static empty(): StepExecutionPath {
|
||||
return new StepExecutionPath([])
|
||||
}
|
||||
|
||||
removeLast(): StepExecutionPath {
|
||||
const newPath = this.path.slice(0, -1)
|
||||
return new StepExecutionPath(newPath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { LATEST_CONTEXT_VERSION } from '@activepieces/pieces-framework'
|
||||
import {
|
||||
FlowActionType,
|
||||
flowStructureUtil,
|
||||
FlowTriggerType,
|
||||
FlowVersion,
|
||||
GenericStepOutput,
|
||||
isNil,
|
||||
LoopStepOutput,
|
||||
RouterStepOutput,
|
||||
spreadIfDefined,
|
||||
StepOutputStatus,
|
||||
} from '@activepieces/shared'
|
||||
import { createPropsResolver } from '../../variables/props-resolver'
|
||||
import { FlowExecutorContext } from './flow-execution-context'
|
||||
|
||||
export const testExecutionContext = {
|
||||
async stateFromFlowVersion({
|
||||
flowVersion,
|
||||
excludedStepName,
|
||||
projectId,
|
||||
engineToken,
|
||||
apiUrl,
|
||||
sampleData,
|
||||
}: TestExecutionParams): Promise<FlowExecutorContext> {
|
||||
let flowExecutionContext = FlowExecutorContext.empty()
|
||||
if (isNil(flowVersion)) {
|
||||
return flowExecutionContext
|
||||
}
|
||||
|
||||
const flowSteps = flowStructureUtil.getAllSteps(flowVersion.trigger)
|
||||
|
||||
for (const step of flowSteps) {
|
||||
const { name } = step
|
||||
if (name === excludedStepName) {
|
||||
continue
|
||||
}
|
||||
|
||||
const stepType = step.type
|
||||
switch (stepType) {
|
||||
case FlowActionType.ROUTER:
|
||||
flowExecutionContext = flowExecutionContext.upsertStep(
|
||||
step.name,
|
||||
RouterStepOutput.create({
|
||||
input: step.settings,
|
||||
type: stepType,
|
||||
status: StepOutputStatus.SUCCEEDED,
|
||||
...spreadIfDefined('output', sampleData?.[step.name]),
|
||||
}),
|
||||
)
|
||||
break
|
||||
case FlowActionType.LOOP_ON_ITEMS: {
|
||||
const { resolvedInput } = await createPropsResolver({
|
||||
apiUrl,
|
||||
projectId,
|
||||
engineToken,
|
||||
contextVersion: LATEST_CONTEXT_VERSION,
|
||||
}).resolve<{ items: unknown[] }>({
|
||||
unresolvedInput: step.settings,
|
||||
executionState: flowExecutionContext,
|
||||
})
|
||||
flowExecutionContext = flowExecutionContext.upsertStep(
|
||||
step.name,
|
||||
LoopStepOutput.init({
|
||||
input: step.settings,
|
||||
}).setOutput({
|
||||
item: resolvedInput.items[0],
|
||||
index: 1,
|
||||
iterations: [],
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case FlowActionType.PIECE:
|
||||
case FlowActionType.CODE:
|
||||
case FlowTriggerType.EMPTY:
|
||||
case FlowTriggerType.PIECE:
|
||||
flowExecutionContext = flowExecutionContext.upsertStep(step.name, GenericStepOutput.create({
|
||||
input: {},
|
||||
type: stepType,
|
||||
status: StepOutputStatus.SUCCEEDED,
|
||||
...spreadIfDefined('output', sampleData?.[step.name]),
|
||||
}))
|
||||
break
|
||||
}
|
||||
}
|
||||
return flowExecutionContext
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
type TestExecutionParams = {
|
||||
flowVersion?: FlowVersion
|
||||
excludedStepName?: string
|
||||
projectId: string
|
||||
apiUrl: string
|
||||
engineToken: string
|
||||
sampleData?: Record<string, unknown>
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { EngineGenericError, ExecuteFlowOperation, ExecutionType, FlowAction, FlowActionType, FlowRunStatus, isNil } from '@activepieces/shared'
|
||||
import { triggerHelper } from '../helper/trigger-helper'
|
||||
import { progressService } from '../services/progress.service'
|
||||
import { BaseExecutor } from './base-executor'
|
||||
import { codeExecutor } from './code-executor'
|
||||
import { EngineConstants } from './context/engine-constants'
|
||||
import { FlowExecutorContext } from './context/flow-execution-context'
|
||||
import { loopExecutor } from './loop-executor'
|
||||
import { pieceExecutor } from './piece-executor'
|
||||
import { routerExecuter } from './router-executor'
|
||||
|
||||
function getExecuteFunction(): Record<FlowActionType, BaseExecutor<FlowAction>> {
|
||||
return {
|
||||
[FlowActionType.CODE]: codeExecutor,
|
||||
[FlowActionType.LOOP_ON_ITEMS]: loopExecutor,
|
||||
[FlowActionType.PIECE]: pieceExecutor,
|
||||
[FlowActionType.ROUTER]: routerExecuter,
|
||||
}
|
||||
}
|
||||
|
||||
export const flowExecutor = {
|
||||
getExecutorForAction(type: FlowActionType): BaseExecutor<FlowAction> {
|
||||
const executeFunction = getExecuteFunction()
|
||||
const executor = executeFunction[type]
|
||||
|
||||
if (isNil(executor)) {
|
||||
throw new EngineGenericError('ExecutorNotFoundError', `Executor not found for action type: ${type}`)
|
||||
}
|
||||
|
||||
return executor
|
||||
},
|
||||
async executeFromTrigger({ executionState, constants, input }: {
|
||||
executionState: FlowExecutorContext
|
||||
constants: EngineConstants
|
||||
input: ExecuteFlowOperation
|
||||
}): Promise<FlowExecutorContext> {
|
||||
const trigger = input.flowVersion.trigger
|
||||
if (input.executionType === ExecutionType.BEGIN) {
|
||||
await triggerHelper.executeOnStart(trigger, constants, input.triggerPayload)
|
||||
}
|
||||
return flowExecutor.execute({
|
||||
action: trigger.nextAction,
|
||||
executionState,
|
||||
constants,
|
||||
})
|
||||
},
|
||||
async execute({ action, constants, executionState }: {
|
||||
action: FlowAction | null | undefined
|
||||
executionState: FlowExecutorContext
|
||||
constants: EngineConstants
|
||||
}): Promise<FlowExecutorContext> {
|
||||
const flowStartTime = performance.now()
|
||||
let flowExecutionContext = executionState
|
||||
let currentAction: FlowAction | null | undefined = action
|
||||
|
||||
while (!isNil(currentAction)) {
|
||||
const testSingleStepMode = !isNil(constants.stepNameToTest)
|
||||
if (currentAction.skip && !testSingleStepMode) {
|
||||
currentAction = currentAction.nextAction
|
||||
continue
|
||||
}
|
||||
const handler = this.getExecutorForAction(currentAction.type)
|
||||
|
||||
progressService.sendUpdate({
|
||||
engineConstants: constants,
|
||||
flowExecutorContext: flowExecutionContext,
|
||||
}).catch(error => {
|
||||
console.error('Error sending update:', error)
|
||||
})
|
||||
|
||||
flowExecutionContext = await handler.handle({
|
||||
action: currentAction,
|
||||
executionState: flowExecutionContext,
|
||||
constants,
|
||||
})
|
||||
const shouldBreakExecution = flowExecutionContext.verdict.status !== FlowRunStatus.RUNNING || testSingleStepMode
|
||||
|
||||
if (shouldBreakExecution) {
|
||||
break
|
||||
}
|
||||
|
||||
currentAction = currentAction.nextAction
|
||||
}
|
||||
|
||||
const flowEndTime = performance.now()
|
||||
return flowExecutionContext.setDuration(flowEndTime - flowStartTime)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { LATEST_CONTEXT_VERSION } from '@activepieces/pieces-framework'
|
||||
import { FlowRunStatus, isNil, LoopOnItemsAction, LoopStepOutput, StepOutputStatus } from '@activepieces/shared'
|
||||
import { BaseExecutor } from './base-executor'
|
||||
import { flowExecutor } from './flow-executor'
|
||||
|
||||
type LoopOnActionResolvedSettings = {
|
||||
items: readonly unknown[]
|
||||
}
|
||||
|
||||
export const loopExecutor: BaseExecutor<LoopOnItemsAction> = {
|
||||
async handle({
|
||||
action,
|
||||
executionState,
|
||||
constants,
|
||||
}) {
|
||||
const stepStartTime = performance.now()
|
||||
const { resolvedInput, censoredInput } = await constants.getPropsResolver(LATEST_CONTEXT_VERSION).resolve<LoopOnActionResolvedSettings>({
|
||||
unresolvedInput: {
|
||||
items: action.settings.items,
|
||||
},
|
||||
executionState,
|
||||
})
|
||||
const previousStepOutput = executionState.getLoopStepOutput({ stepName: action.name })
|
||||
let stepOutput = previousStepOutput ?? LoopStepOutput.init({
|
||||
input: censoredInput,
|
||||
})
|
||||
let newExecutionContext = executionState.upsertStep(action.name, stepOutput)
|
||||
|
||||
if (!Array.isArray(resolvedInput.items)) {
|
||||
const errorMessage = JSON.stringify({
|
||||
message: 'The items you have selected must be a list.',
|
||||
})
|
||||
const failedStepOutput = stepOutput
|
||||
.setStatus(StepOutputStatus.FAILED)
|
||||
.setErrorMessage(errorMessage)
|
||||
.setDuration( performance.now() - stepStartTime)
|
||||
return newExecutionContext.upsertStep(action.name, failedStepOutput).setVerdict({ status: FlowRunStatus.FAILED, failedStep: {
|
||||
name: action.name,
|
||||
displayName: action.displayName,
|
||||
message: errorMessage,
|
||||
} })
|
||||
}
|
||||
|
||||
const firstLoopAction = action.firstLoopAction
|
||||
|
||||
|
||||
for (let i = 0; i < resolvedInput.items.length; ++i) {
|
||||
const newCurrentPath = newExecutionContext.currentPath.loopIteration({ loopName: action.name, iteration: i })
|
||||
|
||||
const testSingleStepMode = !isNil(constants.stepNameToTest)
|
||||
stepOutput = stepOutput.setItemAndIndex({ item: resolvedInput.items[i], index: i + 1 })
|
||||
const addEmptyIteration = !stepOutput.hasIteration(i)
|
||||
if (addEmptyIteration) {
|
||||
stepOutput = stepOutput.addIteration()
|
||||
}
|
||||
newExecutionContext = newExecutionContext.upsertStep(action.name, stepOutput).setCurrentPath(newCurrentPath)
|
||||
if (!isNil(firstLoopAction) && !testSingleStepMode) {
|
||||
newExecutionContext = await flowExecutor.execute({
|
||||
action: firstLoopAction,
|
||||
executionState: newExecutionContext,
|
||||
constants,
|
||||
})
|
||||
}
|
||||
|
||||
newExecutionContext = newExecutionContext.setCurrentPath(newExecutionContext.currentPath.removeLast())
|
||||
|
||||
if (newExecutionContext.verdict.status !== FlowRunStatus.RUNNING) {
|
||||
return newExecutionContext.upsertStep(action.name, stepOutput.setDuration(performance.now() - stepStartTime))
|
||||
}
|
||||
|
||||
if (testSingleStepMode) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return newExecutionContext.upsertStep(action.name, stepOutput.setDuration(performance.now() - stepStartTime))
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { URL } from 'url'
|
||||
import { ActionContext, backwardCompatabilityContextUtils, ConstructToolParams, InputPropertyMap, PauseHook, PauseHookParams, PieceAuthProperty, PiecePropertyMap, RespondHook, RespondHookParams, StaticPropsValue, StopHook, StopHookParams, TagsManager } from '@activepieces/pieces-framework'
|
||||
import { AUTHENTICATION_PROPERTY_NAME, EngineGenericError, EngineSocketEvent, ExecutionType, FlowActionType, FlowRunStatus, GenericStepOutput, isNil, PausedFlowTimeoutError, PauseType, PieceAction, RespondResponse, StepOutputStatus } from '@activepieces/shared'
|
||||
import { LanguageModelV2 } from '@ai-sdk/provider'
|
||||
import { ToolSet } from 'ai'
|
||||
import dayjs from 'dayjs'
|
||||
import { continueIfFailureHandler, runWithExponentialBackoff } from '../helper/error-handling'
|
||||
import { pieceLoader } from '../helper/piece-loader'
|
||||
import { createFlowsContext } from '../services/flows.service'
|
||||
import { progressService } from '../services/progress.service'
|
||||
import { createFilesService } from '../services/step-files.service'
|
||||
import { createContextStore } from '../services/storage.service'
|
||||
import { agentTools } from '../tools'
|
||||
import { HookResponse, utils } from '../utils'
|
||||
import { propsProcessor } from '../variables/props-processor'
|
||||
import { workerSocket } from '../worker-socket'
|
||||
import { ActionHandler, BaseExecutor } from './base-executor'
|
||||
|
||||
const AP_PAUSED_FLOW_TIMEOUT_DAYS = Number(process.env.AP_PAUSED_FLOW_TIMEOUT_DAYS)
|
||||
|
||||
export const pieceExecutor: BaseExecutor<PieceAction> = {
|
||||
async handle({
|
||||
action,
|
||||
executionState,
|
||||
constants,
|
||||
}) {
|
||||
if (executionState.isCompleted({ stepName: action.name })) {
|
||||
return executionState
|
||||
}
|
||||
const resultExecution = await runWithExponentialBackoff(executionState, action, constants, executeAction)
|
||||
return continueIfFailureHandler(resultExecution, action, constants)
|
||||
},
|
||||
}
|
||||
|
||||
const executeAction: ActionHandler<PieceAction> = async ({ action, executionState, constants }) => {
|
||||
const stepStartTime = performance.now()
|
||||
const stepOutput = GenericStepOutput.create({
|
||||
input: {},
|
||||
type: FlowActionType.PIECE,
|
||||
status: StepOutputStatus.RUNNING,
|
||||
})
|
||||
|
||||
const { data: executionStateResult, error: executionStateError } = await utils.tryCatchAndThrowOnEngineError((async () => {
|
||||
if (isNil(action.settings.actionName)) {
|
||||
throw new EngineGenericError('ActionNameNotSetError', 'Action name is not set')
|
||||
}
|
||||
|
||||
const { pieceAction, piece } = await pieceLoader.getPieceAndActionOrThrow({
|
||||
pieceName: action.settings.pieceName,
|
||||
pieceVersion: action.settings.pieceVersion,
|
||||
actionName: action.settings.actionName,
|
||||
devPieces: constants.devPieces,
|
||||
})
|
||||
|
||||
const { resolvedInput, censoredInput } = await constants.getPropsResolver(piece.getContextInfo?.().version).resolve<StaticPropsValue<PiecePropertyMap>>({
|
||||
unresolvedInput: action.settings.input,
|
||||
executionState,
|
||||
})
|
||||
|
||||
stepOutput.input = censoredInput
|
||||
|
||||
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(resolvedInput, pieceAction.props, piece.auth, pieceAction.requireAuth, action.settings.propertySettings)
|
||||
if (Object.keys(errors).length > 0) {
|
||||
throw new Error(JSON.stringify(errors, null, 2))
|
||||
}
|
||||
|
||||
|
||||
const params: {
|
||||
hookResponse: HookResponse
|
||||
} = {
|
||||
hookResponse: {
|
||||
type: 'none',
|
||||
tags: [],
|
||||
},
|
||||
}
|
||||
const outputContext = progressService.createOutputContext({
|
||||
engineConstants: constants,
|
||||
flowExecutorContext: executionState,
|
||||
stepName: action.name,
|
||||
stepOutput,
|
||||
})
|
||||
|
||||
const isPaused = executionState.isPaused({ stepName: action.name })
|
||||
if (!isPaused) {
|
||||
await progressService.sendUpdate({
|
||||
engineConstants: constants,
|
||||
flowExecutorContext: executionState.upsertStep(action.name, stepOutput),
|
||||
})
|
||||
}
|
||||
const context: ActionContext<PieceAuthProperty, InputPropertyMap> = {
|
||||
executionType: isPaused ? ExecutionType.RESUME : ExecutionType.BEGIN,
|
||||
resumePayload: constants.resumePayload!,
|
||||
store: createContextStore({
|
||||
apiUrl: constants.internalApiUrl,
|
||||
prefix: '',
|
||||
flowId: constants.flowId,
|
||||
engineToken: constants.engineToken,
|
||||
}),
|
||||
output: outputContext,
|
||||
flows: createFlowsContext({
|
||||
engineToken: constants.engineToken,
|
||||
internalApiUrl: constants.internalApiUrl,
|
||||
flowId: constants.flowId,
|
||||
flowVersionId: constants.flowVersionId,
|
||||
}),
|
||||
step: {
|
||||
name: action.name,
|
||||
},
|
||||
auth: processedInput[AUTHENTICATION_PROPERTY_NAME],
|
||||
files: createFilesService({
|
||||
apiUrl: constants.internalApiUrl,
|
||||
engineToken: constants.engineToken,
|
||||
stepName: action.name,
|
||||
flowId: constants.flowId,
|
||||
}),
|
||||
server: {
|
||||
token: constants.engineToken,
|
||||
apiUrl: constants.internalApiUrl,
|
||||
publicUrl: constants.publicApiUrl,
|
||||
},
|
||||
agent: {
|
||||
tools: async (params: ConstructToolParams): Promise<ToolSet> => agentTools.tools({
|
||||
engineConstants: constants,
|
||||
tools: params.tools,
|
||||
model: params.model as LanguageModelV2,
|
||||
}),
|
||||
},
|
||||
propsValue: processedInput,
|
||||
tags: createTagsManager(params),
|
||||
connections: utils.createConnectionManager({
|
||||
apiUrl: constants.internalApiUrl,
|
||||
projectId: constants.projectId,
|
||||
engineToken: constants.engineToken,
|
||||
target: 'actions',
|
||||
hookResponse: params.hookResponse,
|
||||
contextVersion: piece.getContextInfo?.().version,
|
||||
}),
|
||||
run: {
|
||||
id: constants.flowRunId,
|
||||
stop: createStopHook(params),
|
||||
pause: createPauseHook(params, executionState.pauseRequestId, constants.httpRequestId),
|
||||
respond: createRespondHook(params),
|
||||
},
|
||||
project: {
|
||||
id: constants.projectId,
|
||||
externalId: constants.externalProjectId,
|
||||
},
|
||||
generateResumeUrl: (params) => {
|
||||
const url = new URL(`${constants.publicApiUrl}v1/flow-runs/${constants.flowRunId}/requests/${executionState.pauseRequestId}${params.sync ? '/sync' : ''}`)
|
||||
url.search = new URLSearchParams(params.queryParams).toString()
|
||||
return url.toString()
|
||||
},
|
||||
}
|
||||
const backwardCompatibleContext = backwardCompatabilityContextUtils.makeActionContextBackwardCompatible({
|
||||
contextVersion: piece.getContextInfo?.().version,
|
||||
context,
|
||||
})
|
||||
const testSingleStepMode = !isNil(constants.stepNameToTest)
|
||||
const runMethodToExecute = (testSingleStepMode && !isNil(pieceAction.test)) ? pieceAction.test : pieceAction.run
|
||||
const output = await runMethodToExecute(backwardCompatibleContext)
|
||||
const newExecutionContext = executionState.addTags(params.hookResponse.tags)
|
||||
|
||||
const webhookResponse = getResponse(params.hookResponse)
|
||||
const isSamePiece = constants.triggerPieceName === action.settings.pieceName
|
||||
if (!isNil(webhookResponse) && !isNil(constants.serverHandlerId) && !isNil(constants.httpRequestId) && isSamePiece) {
|
||||
await workerSocket.sendToWorkerWithAck(EngineSocketEvent.SEND_FLOW_RESPONSE, {
|
||||
workerHandlerId: constants.serverHandlerId,
|
||||
httpRequestId: constants.httpRequestId,
|
||||
runResponse: {
|
||||
status: webhookResponse.status ?? 200,
|
||||
body: webhookResponse.body ?? {},
|
||||
headers: webhookResponse.headers ?? {},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const stepEndTime = performance.now()
|
||||
if (params.hookResponse.type === 'stopped') {
|
||||
if (isNil(params.hookResponse.response)) {
|
||||
throw new EngineGenericError('StopResponseNotSetError', 'Stop response is not set')
|
||||
}
|
||||
|
||||
return newExecutionContext.upsertStep(action.name, stepOutput.setOutput(output).setStatus(StepOutputStatus.SUCCEEDED).setDuration(stepEndTime - stepStartTime)).incrementStepsExecuted().setVerdict({
|
||||
status: FlowRunStatus.SUCCEEDED,
|
||||
stopResponse: (params.hookResponse.response as StopHookParams).response,
|
||||
})
|
||||
}
|
||||
if (params.hookResponse.type === 'paused') {
|
||||
if (isNil(params.hookResponse.response)) {
|
||||
throw new EngineGenericError('PauseResponseNotSetError', 'Pause response is not set')
|
||||
}
|
||||
|
||||
return newExecutionContext.upsertStep(action.name, stepOutput.setOutput(output).setStatus(StepOutputStatus.PAUSED).setDuration(stepEndTime - stepStartTime)).incrementStepsExecuted()
|
||||
.setVerdict({
|
||||
status: FlowRunStatus.PAUSED,
|
||||
pauseMetadata: (params.hookResponse.response as PauseHookParams).pauseMetadata,
|
||||
})
|
||||
}
|
||||
return newExecutionContext.upsertStep(action.name, stepOutput.setOutput(output).setStatus(StepOutputStatus.SUCCEEDED).setDuration(stepEndTime - stepStartTime)).incrementStepsExecuted().setVerdict({ status: FlowRunStatus.RUNNING })
|
||||
|
||||
}))
|
||||
|
||||
if (executionStateError) {
|
||||
const failedStepOutput = stepOutput
|
||||
.setStatus(StepOutputStatus.FAILED)
|
||||
.setErrorMessage(utils.formatError(executionStateError))
|
||||
.setDuration(performance.now() - stepStartTime)
|
||||
|
||||
return executionState
|
||||
.upsertStep(action.name, failedStepOutput)
|
||||
.setVerdict({
|
||||
status: FlowRunStatus.FAILED, failedStep: {
|
||||
name: action.name,
|
||||
displayName: action.displayName,
|
||||
message: utils.formatError(executionStateError),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return executionStateResult
|
||||
}
|
||||
|
||||
function getResponse(hookResponse: HookResponse): RespondResponse | undefined {
|
||||
switch (hookResponse.type) {
|
||||
case 'stopped':
|
||||
case 'respond':
|
||||
return hookResponse.response.response
|
||||
case 'paused':
|
||||
if (hookResponse.response.pauseMetadata.type === PauseType.WEBHOOK) {
|
||||
return hookResponse.response.pauseMetadata.response
|
||||
}
|
||||
else {
|
||||
return undefined
|
||||
}
|
||||
case 'none':
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const createTagsManager = (hkParams: createTagsManagerParams): TagsManager => {
|
||||
return {
|
||||
add: async (params: addTagsParams): Promise<void> => {
|
||||
hkParams.hookResponse.tags.push(params.name)
|
||||
},
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
type addTagsParams = {
|
||||
name: string
|
||||
}
|
||||
|
||||
type createTagsManagerParams = {
|
||||
hookResponse: HookResponse
|
||||
}
|
||||
|
||||
|
||||
function createStopHook(params: CreateStopHookParams): StopHook {
|
||||
return (req?: StopHookParams) => {
|
||||
params.hookResponse = {
|
||||
...params.hookResponse,
|
||||
type: 'stopped',
|
||||
response: req ?? { response: {} },
|
||||
}
|
||||
}
|
||||
}
|
||||
type CreateStopHookParams = {
|
||||
hookResponse: HookResponse
|
||||
}
|
||||
|
||||
function createRespondHook(params: CreateRespondHookParams): RespondHook {
|
||||
return (req?: RespondHookParams) => {
|
||||
params.hookResponse = {
|
||||
...params.hookResponse,
|
||||
type: 'respond',
|
||||
response: req ?? { response: {} },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type CreateRespondHookParams = {
|
||||
hookResponse: HookResponse
|
||||
}
|
||||
|
||||
function createPauseHook(params: CreatePauseHookParams, pauseId: string, requestIdToReply: string | null): PauseHook {
|
||||
return (req) => {
|
||||
switch (req.pauseMetadata.type) {
|
||||
case PauseType.DELAY: {
|
||||
const diffInDays = dayjs(req.pauseMetadata.resumeDateTime).diff(dayjs(), 'days')
|
||||
if (diffInDays > AP_PAUSED_FLOW_TIMEOUT_DAYS) {
|
||||
throw new PausedFlowTimeoutError(undefined, AP_PAUSED_FLOW_TIMEOUT_DAYS)
|
||||
}
|
||||
params.hookResponse = {
|
||||
...params.hookResponse,
|
||||
type: 'paused',
|
||||
response: {
|
||||
pauseMetadata: {
|
||||
...req.pauseMetadata,
|
||||
requestIdToReply: requestIdToReply ?? undefined,
|
||||
},
|
||||
},
|
||||
}
|
||||
break
|
||||
}
|
||||
case PauseType.WEBHOOK:
|
||||
params.hookResponse = {
|
||||
...params.hookResponse,
|
||||
type: 'paused',
|
||||
response: {
|
||||
pauseMetadata: {
|
||||
...req.pauseMetadata,
|
||||
requestId: pauseId,
|
||||
requestIdToReply: requestIdToReply ?? undefined,
|
||||
response: req.pauseMetadata.response ?? {},
|
||||
},
|
||||
},
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type CreatePauseHookParams = {
|
||||
hookResponse: HookResponse
|
||||
}
|
||||
284
activepieces-fork/packages/engine/src/lib/handler/router-executor.ts
Executable file
284
activepieces-fork/packages/engine/src/lib/handler/router-executor.ts
Executable file
@@ -0,0 +1,284 @@
|
||||
import { LATEST_CONTEXT_VERSION } from '@activepieces/pieces-framework'
|
||||
import { BranchCondition, BranchExecutionType, BranchOperator, EngineGenericError, FlowRunStatus, isNil, RouterAction, RouterActionSettings, RouterExecutionType, RouterStepOutput, StepOutputStatus } from '@activepieces/shared'
|
||||
import dayjs from 'dayjs'
|
||||
import { utils } from '../utils'
|
||||
import { BaseExecutor } from './base-executor'
|
||||
import { EngineConstants } from './context/engine-constants'
|
||||
import { FlowExecutorContext } from './context/flow-execution-context'
|
||||
import { flowExecutor } from './flow-executor'
|
||||
|
||||
export const routerExecuter: BaseExecutor<RouterAction> = {
|
||||
async handle({
|
||||
action,
|
||||
executionState,
|
||||
constants,
|
||||
}) {
|
||||
const { censoredInput, resolvedInput } = await constants.getPropsResolver(LATEST_CONTEXT_VERSION).resolve<RouterActionSettings>({
|
||||
unresolvedInput: {
|
||||
...action.settings,
|
||||
},
|
||||
executionState,
|
||||
})
|
||||
|
||||
switch (resolvedInput.executionType) {
|
||||
case RouterExecutionType.EXECUTE_ALL_MATCH:
|
||||
return handleRouterExecution({ action, executionState, constants, censoredInput, resolvedInput, routerExecutionType: RouterExecutionType.EXECUTE_ALL_MATCH })
|
||||
case RouterExecutionType.EXECUTE_FIRST_MATCH:
|
||||
return handleRouterExecution({ action, executionState, constants, censoredInput, resolvedInput, routerExecutionType: RouterExecutionType.EXECUTE_FIRST_MATCH })
|
||||
default:
|
||||
throw new EngineGenericError('RouterExecutionTypeNotSupportedError', `Router execution type ${resolvedInput.executionType} is not supported`)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async function handleRouterExecution({ action, executionState, constants, censoredInput, resolvedInput, routerExecutionType }: {
|
||||
action: RouterAction
|
||||
executionState: FlowExecutorContext
|
||||
constants: EngineConstants
|
||||
censoredInput: unknown
|
||||
resolvedInput: RouterActionSettings
|
||||
routerExecutionType: RouterExecutionType
|
||||
}): Promise<FlowExecutorContext> {
|
||||
const stepStartTime = performance.now()
|
||||
|
||||
const evaluatedConditionsWithoutFallback = resolvedInput.branches.map((branch) => {
|
||||
return branch.branchType === BranchExecutionType.FALLBACK ? true : evaluateConditions(branch.conditions)
|
||||
})
|
||||
|
||||
const evaluatedConditions = resolvedInput.branches.map((branch, index) => {
|
||||
if (branch.branchType === BranchExecutionType.CONDITION) {
|
||||
return evaluatedConditionsWithoutFallback[index]
|
||||
}
|
||||
const fallback = evaluatedConditionsWithoutFallback.filter((_, i) => i !== index).every((condition) => !condition)
|
||||
return fallback
|
||||
})
|
||||
|
||||
const stepEndTime = performance.now()
|
||||
const routerOutput = RouterStepOutput.init({
|
||||
input: censoredInput,
|
||||
}).setOutput({
|
||||
branches: resolvedInput.branches.map((branch, index) => ({
|
||||
branchName: branch.branchName,
|
||||
branchIndex: index + 1,
|
||||
evaluation: evaluatedConditions[index],
|
||||
})),
|
||||
}).setDuration(stepEndTime - stepStartTime)
|
||||
executionState = executionState.upsertStep(action.name, routerOutput)
|
||||
|
||||
const { data: executionStateResult, error: executionStateError } = await utils.tryCatchAndThrowOnEngineError(async () => {
|
||||
for (let i = 0; i < resolvedInput.branches.length; i++) {
|
||||
if (!isNil(constants.stepNameToTest)) {
|
||||
break
|
||||
}
|
||||
const condition = routerOutput.output?.branches[i].evaluation
|
||||
if (!condition) {
|
||||
continue
|
||||
}
|
||||
|
||||
executionState = await flowExecutor.execute({
|
||||
action: action.children[i],
|
||||
executionState,
|
||||
constants,
|
||||
})
|
||||
|
||||
const shouldBreakExecution = executionState.verdict.status !== FlowRunStatus.RUNNING || routerExecutionType === RouterExecutionType.EXECUTE_FIRST_MATCH
|
||||
if (shouldBreakExecution) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return executionState
|
||||
})
|
||||
if (executionStateError) {
|
||||
const failedStepOutput = routerOutput.setStatus(StepOutputStatus.FAILED)
|
||||
return executionState.upsertStep(action.name, failedStepOutput).setVerdict({ status: FlowRunStatus.FAILED, failedStep: {
|
||||
name: action.name,
|
||||
displayName: action.displayName,
|
||||
message: utils.formatError(executionStateError),
|
||||
} })
|
||||
}
|
||||
|
||||
return executionStateResult
|
||||
}
|
||||
|
||||
|
||||
export function evaluateConditions(conditionGroups: BranchCondition[][]): boolean {
|
||||
let orOperator = false
|
||||
for (const conditionGroup of conditionGroups) {
|
||||
let andGroup = true
|
||||
for (const condition of conditionGroup) {
|
||||
const castedCondition = condition
|
||||
|
||||
if (isNil(castedCondition.operator)) {
|
||||
throw new EngineGenericError('OperatorNotSetError', 'The operator is required but found to be undefined')
|
||||
}
|
||||
|
||||
switch (castedCondition.operator) {
|
||||
case BranchOperator.TEXT_CONTAINS: {
|
||||
const firstValueContains = toLowercaseIfCaseInsensitive(castedCondition.firstValue, castedCondition.caseSensitive).includes(
|
||||
toLowercaseIfCaseInsensitive(castedCondition.secondValue, castedCondition.caseSensitive),
|
||||
)
|
||||
andGroup = andGroup && firstValueContains
|
||||
break
|
||||
}
|
||||
case BranchOperator.TEXT_DOES_NOT_CONTAIN: {
|
||||
const firstValueDoesNotContain = !toLowercaseIfCaseInsensitive(castedCondition.firstValue, castedCondition.caseSensitive).includes(
|
||||
toLowercaseIfCaseInsensitive(castedCondition.secondValue, castedCondition.caseSensitive),
|
||||
)
|
||||
andGroup = andGroup && firstValueDoesNotContain
|
||||
break
|
||||
}
|
||||
case BranchOperator.TEXT_EXACTLY_MATCHES: {
|
||||
const firstValueExactlyMatches = toLowercaseIfCaseInsensitive(castedCondition.firstValue, castedCondition.caseSensitive) ===
|
||||
toLowercaseIfCaseInsensitive(castedCondition.secondValue, castedCondition.caseSensitive)
|
||||
andGroup = andGroup && firstValueExactlyMatches
|
||||
break
|
||||
}
|
||||
case BranchOperator.TEXT_DOES_NOT_EXACTLY_MATCH: {
|
||||
const firstValueDoesNotExactlyMatch = toLowercaseIfCaseInsensitive(castedCondition.firstValue, castedCondition.caseSensitive) !==
|
||||
toLowercaseIfCaseInsensitive(castedCondition.secondValue, castedCondition.caseSensitive)
|
||||
andGroup = andGroup && firstValueDoesNotExactlyMatch
|
||||
break
|
||||
}
|
||||
case BranchOperator.TEXT_STARTS_WITH: {
|
||||
const firstValueStartsWith = toLowercaseIfCaseInsensitive(castedCondition.firstValue, castedCondition.caseSensitive).startsWith(
|
||||
toLowercaseIfCaseInsensitive(castedCondition.secondValue, castedCondition.caseSensitive),
|
||||
)
|
||||
andGroup = andGroup && firstValueStartsWith
|
||||
break
|
||||
}
|
||||
case BranchOperator.TEXT_ENDS_WITH: {
|
||||
const firstValueEndsWith = toLowercaseIfCaseInsensitive(castedCondition.firstValue, castedCondition.caseSensitive).endsWith(
|
||||
toLowercaseIfCaseInsensitive(castedCondition.secondValue, castedCondition.caseSensitive),
|
||||
)
|
||||
andGroup = andGroup && firstValueEndsWith
|
||||
break
|
||||
}
|
||||
case BranchOperator.TEXT_DOES_NOT_START_WITH: {
|
||||
const firstValueDoesNotStartWith = !toLowercaseIfCaseInsensitive(castedCondition.firstValue, castedCondition.caseSensitive).startsWith(
|
||||
toLowercaseIfCaseInsensitive(castedCondition.secondValue, castedCondition.caseSensitive),
|
||||
)
|
||||
andGroup = andGroup && firstValueDoesNotStartWith
|
||||
break
|
||||
}
|
||||
case BranchOperator.TEXT_DOES_NOT_END_WITH: {
|
||||
const firstValueDoesNotEndWith = !toLowercaseIfCaseInsensitive(castedCondition.firstValue, castedCondition.caseSensitive).endsWith(
|
||||
toLowercaseIfCaseInsensitive(castedCondition.secondValue, castedCondition.caseSensitive),
|
||||
)
|
||||
andGroup = andGroup && firstValueDoesNotEndWith
|
||||
break
|
||||
}
|
||||
case BranchOperator.LIST_CONTAINS: {
|
||||
const list = parseAndCoerceListAsArray(castedCondition.firstValue)
|
||||
andGroup = andGroup && list.some((item) =>
|
||||
toLowercaseIfCaseInsensitive(item, castedCondition.caseSensitive) === toLowercaseIfCaseInsensitive(castedCondition.secondValue, castedCondition.caseSensitive),
|
||||
)
|
||||
break
|
||||
}
|
||||
case BranchOperator.LIST_DOES_NOT_CONTAIN: {
|
||||
const list = parseAndCoerceListAsArray(castedCondition.firstValue)
|
||||
andGroup = andGroup && !list.some((item) =>
|
||||
toLowercaseIfCaseInsensitive(item, castedCondition.caseSensitive) === toLowercaseIfCaseInsensitive(castedCondition.secondValue, castedCondition.caseSensitive),
|
||||
)
|
||||
break
|
||||
}
|
||||
case BranchOperator.NUMBER_IS_GREATER_THAN: {
|
||||
const firstValue = parseStringToNumber(castedCondition.firstValue)
|
||||
const secondValue = parseStringToNumber(castedCondition.secondValue)
|
||||
andGroup = andGroup && firstValue > secondValue
|
||||
break
|
||||
}
|
||||
case BranchOperator.NUMBER_IS_LESS_THAN: {
|
||||
const firstValue = parseStringToNumber(castedCondition.firstValue)
|
||||
const secondValue = parseStringToNumber(castedCondition.secondValue)
|
||||
andGroup = andGroup && firstValue < secondValue
|
||||
break
|
||||
}
|
||||
case BranchOperator.NUMBER_IS_EQUAL_TO: {
|
||||
const firstValue = parseStringToNumber(castedCondition.firstValue)
|
||||
const secondValue = parseStringToNumber(castedCondition.secondValue)
|
||||
andGroup = andGroup && firstValue == secondValue
|
||||
break
|
||||
}
|
||||
case BranchOperator.BOOLEAN_IS_TRUE:
|
||||
andGroup = andGroup && !!castedCondition.firstValue
|
||||
break
|
||||
case BranchOperator.BOOLEAN_IS_FALSE:
|
||||
andGroup = andGroup && !castedCondition.firstValue
|
||||
break
|
||||
case BranchOperator.DATE_IS_AFTER:
|
||||
andGroup = andGroup && isValidDate(castedCondition.firstValue) && isValidDate(castedCondition.secondValue) && dayjs(castedCondition.firstValue).isAfter(dayjs(castedCondition.secondValue))
|
||||
break
|
||||
case BranchOperator.DATE_IS_EQUAL:
|
||||
andGroup = andGroup && isValidDate(castedCondition.firstValue) && isValidDate(castedCondition.secondValue) && dayjs(castedCondition.firstValue).isSame(dayjs(castedCondition.secondValue))
|
||||
break
|
||||
case BranchOperator.DATE_IS_BEFORE:
|
||||
andGroup = andGroup && isValidDate(castedCondition.firstValue) && isValidDate(castedCondition.secondValue) && dayjs(castedCondition.firstValue).isBefore(dayjs(castedCondition.secondValue))
|
||||
break
|
||||
case BranchOperator.LIST_IS_EMPTY: {
|
||||
const list = parseListAsArray(castedCondition.firstValue)
|
||||
andGroup = andGroup && Array.isArray(list) && list?.length === 0
|
||||
break
|
||||
}
|
||||
case BranchOperator.LIST_IS_NOT_EMPTY: {
|
||||
const list = parseListAsArray(castedCondition.firstValue)
|
||||
andGroup = andGroup && Array.isArray(list) && list?.length !== 0
|
||||
break
|
||||
}
|
||||
case BranchOperator.EXISTS:
|
||||
andGroup = andGroup && castedCondition.firstValue !== undefined && castedCondition.firstValue !== null && castedCondition.firstValue !== ''
|
||||
break
|
||||
case BranchOperator.DOES_NOT_EXIST:
|
||||
andGroup = andGroup && (castedCondition.firstValue === undefined || castedCondition.firstValue === null || castedCondition.firstValue === '')
|
||||
break
|
||||
}
|
||||
}
|
||||
orOperator = orOperator || andGroup
|
||||
}
|
||||
return Boolean(orOperator)
|
||||
}
|
||||
|
||||
function toLowercaseIfCaseInsensitive(text: unknown, caseSensitive: boolean | undefined): string {
|
||||
if (typeof text === 'string') {
|
||||
return caseSensitive ? text : text.toLowerCase()
|
||||
}
|
||||
const textAsString = JSON.stringify(text)
|
||||
return caseSensitive ? textAsString : textAsString.toLowerCase()
|
||||
}
|
||||
|
||||
function parseStringToNumber(str: string): number | string {
|
||||
const num = Number(str)
|
||||
return isNaN(num) ? str : num
|
||||
}
|
||||
|
||||
function parseListAsArray(input: unknown): unknown[] | undefined {
|
||||
if (typeof input === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(input)
|
||||
return Array.isArray(parsed) ? parsed : undefined
|
||||
}
|
||||
catch (e) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return Array.isArray(input) ? input : undefined
|
||||
}
|
||||
|
||||
function parseAndCoerceListAsArray(input: unknown): unknown[] {
|
||||
if (typeof input === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(input)
|
||||
return Array.isArray(parsed) ? parsed : [parsed]
|
||||
}
|
||||
catch (e) {
|
||||
return [input]
|
||||
}
|
||||
}
|
||||
return Array.isArray(input) ? input : [input]
|
||||
}
|
||||
|
||||
function isValidDate(date: unknown): boolean {
|
||||
if (typeof date === 'string' || typeof date === 'number' || date instanceof Date) {
|
||||
return dayjs(date).isValid()
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { CodeAction, FlowRunStatus, isNil, PieceAction } from '@activepieces/shared'
|
||||
import { EngineConstants } from '../handler/context/engine-constants'
|
||||
import { FlowExecutorContext } from '../handler/context/flow-execution-context'
|
||||
|
||||
export async function runWithExponentialBackoff<T extends CodeAction | PieceAction>(
|
||||
executionState: FlowExecutorContext,
|
||||
action: T,
|
||||
constants: EngineConstants,
|
||||
requestFunction: RequestFunction<T>,
|
||||
attemptCount = 1,
|
||||
): Promise<FlowExecutorContext> {
|
||||
const resultExecutionState = await requestFunction({ action, executionState, constants })
|
||||
const retryEnabled = action.settings.errorHandlingOptions?.retryOnFailure?.value
|
||||
if (
|
||||
executionFailedWithRetryableError(resultExecutionState) &&
|
||||
attemptCount < constants.retryConstants.maxAttempts &&
|
||||
retryEnabled &&
|
||||
isNil(constants.stepNameToTest)
|
||||
) {
|
||||
const backoffTime = Math.pow(constants.retryConstants.retryExponential, attemptCount) * constants.retryConstants.retryInterval
|
||||
await new Promise(resolve => setTimeout(resolve, backoffTime))
|
||||
return runWithExponentialBackoff(executionState, action, constants, requestFunction, attemptCount + 1)
|
||||
}
|
||||
|
||||
return resultExecutionState
|
||||
}
|
||||
|
||||
export async function continueIfFailureHandler(
|
||||
executionState: FlowExecutorContext,
|
||||
action: CodeAction | PieceAction,
|
||||
constants: EngineConstants,
|
||||
): Promise<FlowExecutorContext> {
|
||||
const continueOnFailure = action.settings.errorHandlingOptions?.continueOnFailure?.value
|
||||
|
||||
if (
|
||||
executionState.verdict.status === FlowRunStatus.FAILED &&
|
||||
continueOnFailure &&
|
||||
isNil(constants.stepNameToTest)
|
||||
) {
|
||||
return executionState
|
||||
.setVerdict({ status: FlowRunStatus.RUNNING })
|
||||
}
|
||||
|
||||
return executionState
|
||||
}
|
||||
|
||||
|
||||
const executionFailedWithRetryableError = (flowExecutorContext: FlowExecutorContext): boolean => {
|
||||
return flowExecutorContext.verdict.status === FlowRunStatus.FAILED
|
||||
}
|
||||
|
||||
type Request<T extends CodeAction | PieceAction> = {
|
||||
action: T
|
||||
executionState: FlowExecutorContext
|
||||
constants: EngineConstants
|
||||
}
|
||||
|
||||
type RequestFunction<T extends CodeAction | PieceAction> = (request: Request<T>) => Promise<FlowExecutorContext>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { isObject, StepOutput } from '@activepieces/shared'
|
||||
import { Queue } from '@datastructures-js/queue'
|
||||
import sizeof from 'object-sizeof'
|
||||
import PriorityQueue from 'priority-queue-typescript'
|
||||
|
||||
const TRUNCATION_TEXT_PLACEHOLDER = '(truncated)'
|
||||
const ERROR_OFFSET = 256 * 1024
|
||||
const DEFAULT_MAX_LOG_SIZE_FOR_TESTING = '10'
|
||||
const MAX_LOG_SIZE = Number(process.env.AP_MAX_FILE_SIZE_MB ?? DEFAULT_MAX_LOG_SIZE_FOR_TESTING) * 1024 * 1024
|
||||
const MAX_SIZE_FOR_ALL_ENTRIES = MAX_LOG_SIZE - ERROR_OFFSET
|
||||
const SIZE_OF_TRUNCATION_TEXT_PLACEHOLDER = sizeof(TRUNCATION_TEXT_PLACEHOLDER)
|
||||
const nonTruncatableKeys: Key[] = ['status', 'duration', 'type']
|
||||
|
||||
export const loggingUtils = {
|
||||
async trimExecution(steps: Record<string, StepOutput>): Promise<Record<string, StepOutput>> {
|
||||
const totalJsonSize = sizeof(steps)
|
||||
if (!jsonExceedMaxSize(totalJsonSize)) {
|
||||
return steps
|
||||
}
|
||||
return removeLeavesInTopologicalOrder(JSON.parse(JSON.stringify(steps)))
|
||||
},
|
||||
}
|
||||
|
||||
function removeLeavesInTopologicalOrder(json: Record<string, unknown>): Record<string, StepOutput> {
|
||||
const nodes: Node[] = traverseJsonAndConvertToNodes(json)
|
||||
const leaves = new PriorityQueue<Node>(
|
||||
undefined,
|
||||
(a: Node, b: Node) => b.size - a.size,
|
||||
)
|
||||
nodes.filter((node) => node.numberOfChildren === 0).forEach((node) => leaves.add(node))
|
||||
let totalJsonSize = sizeof(json)
|
||||
|
||||
while (!leaves.empty() && jsonExceedMaxSize(totalJsonSize)) {
|
||||
const curNode = leaves.poll()
|
||||
|
||||
const isDepthGreaterThanOne = curNode && curNode.depth > 1
|
||||
const isTruncatable = curNode && (!nonTruncatableKeys.includes(curNode.key))
|
||||
|
||||
if (isDepthGreaterThanOne && isTruncatable) {
|
||||
totalJsonSize += SIZE_OF_TRUNCATION_TEXT_PLACEHOLDER - curNode.size
|
||||
|
||||
const parent = curNode.parent
|
||||
|
||||
parent.value[curNode.key] = TRUNCATION_TEXT_PLACEHOLDER
|
||||
|
||||
nodes[parent.index].numberOfChildren--
|
||||
if (nodes[parent.index].numberOfChildren == 0) {
|
||||
leaves.add(nodes[parent.index])
|
||||
}
|
||||
}
|
||||
}
|
||||
return json as Record<string, StepOutput>
|
||||
}
|
||||
|
||||
function traverseJsonAndConvertToNodes(root: unknown) {
|
||||
|
||||
const nodesQueue = new Queue<BfsNode>()
|
||||
nodesQueue.enqueue({ key: '', value: root, parent: { index: -1, value: {} }, depth: 0 })
|
||||
|
||||
const nodes: Node[] = []
|
||||
|
||||
while (!nodesQueue.isEmpty()) {
|
||||
const curNode = nodesQueue.dequeue()
|
||||
const children = findChildren(curNode.value, curNode.key === 'iterations')
|
||||
|
||||
nodes.push({
|
||||
index: nodes.length,
|
||||
size: children.length === 0 ? sizeof(curNode.value) : children.length * SIZE_OF_TRUNCATION_TEXT_PLACEHOLDER,
|
||||
key: curNode.key,
|
||||
parent: {
|
||||
index: curNode.parent.index,
|
||||
value: curNode.parent.value as Record<Key, unknown>,
|
||||
},
|
||||
numberOfChildren: children.length,
|
||||
depth: curNode.depth,
|
||||
})
|
||||
|
||||
children.forEach((child) => {
|
||||
const key = child[0], value = child[1]
|
||||
nodesQueue.enqueue({ value, key, parent: { index: nodes.length - 1, value: curNode.value }, depth: curNode.depth + 1 })
|
||||
})
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
function findChildren(curNode: unknown, traverseArray: boolean): [Key, unknown][] {
|
||||
if (isObject(curNode)) {
|
||||
return Object.entries(curNode)
|
||||
}
|
||||
// Array should be treated as a leaf node as If it has too many small items, It will prioritize the other steps first
|
||||
if (Array.isArray(curNode) && traverseArray) {
|
||||
const children: [Key, unknown][] = []
|
||||
for (let i = 0; i < curNode.length; i++) {
|
||||
children.push([i, curNode[i]])
|
||||
}
|
||||
return children
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const jsonExceedMaxSize = (jsonSize: number): boolean => {
|
||||
return jsonSize > MAX_SIZE_FOR_ALL_ENTRIES
|
||||
}
|
||||
|
||||
type Node = {
|
||||
index: number
|
||||
size: number
|
||||
key: Key
|
||||
parent: {
|
||||
index: number
|
||||
value: Record<Key, unknown>
|
||||
}
|
||||
numberOfChildren: number
|
||||
depth: number
|
||||
}
|
||||
|
||||
type BfsNode = {
|
||||
value: unknown
|
||||
key: Key
|
||||
parent: {
|
||||
index: number
|
||||
value: unknown
|
||||
}
|
||||
depth: number
|
||||
}
|
||||
|
||||
type Key = string | number | symbol
|
||||
253
activepieces-fork/packages/engine/src/lib/helper/piece-helper.ts
Normal file
253
activepieces-fork/packages/engine/src/lib/helper/piece-helper.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
DropdownProperty,
|
||||
DynamicProperties,
|
||||
ExecutePropsResult,
|
||||
getAuthPropertyForValue,
|
||||
MultiSelectDropdownProperty,
|
||||
PieceAuthProperty,
|
||||
PieceMetadata,
|
||||
PiecePropertyMap,
|
||||
pieceTranslation,
|
||||
PropertyType,
|
||||
StaticPropsValue } from '@activepieces/pieces-framework'
|
||||
import {
|
||||
AppConnectionType,
|
||||
AppConnectionValue,
|
||||
EngineGenericError,
|
||||
ExecuteExtractPieceMetadata,
|
||||
ExecutePropsOptions,
|
||||
ExecuteValidateAuthOperation,
|
||||
ExecuteValidateAuthResponse,
|
||||
isNil,
|
||||
} from '@activepieces/shared'
|
||||
import { EngineConstants } from '../handler/context/engine-constants'
|
||||
import { testExecutionContext } from '../handler/context/test-execution-context'
|
||||
import { createFlowsContext } from '../services/flows.service'
|
||||
import { utils } from '../utils'
|
||||
import { createPropsResolver } from '../variables/props-resolver'
|
||||
import { pieceLoader } from './piece-loader'
|
||||
|
||||
export const pieceHelper = {
|
||||
async executeProps( operation: ExecutePropsParams): Promise<ExecutePropsResult<PropertyType.DROPDOWN | PropertyType.MULTI_SELECT_DROPDOWN | PropertyType.DYNAMIC>> {
|
||||
const constants = EngineConstants.fromExecutePropertyInput(operation)
|
||||
const executionState = await testExecutionContext.stateFromFlowVersion({
|
||||
apiUrl: operation.internalApiUrl,
|
||||
flowVersion: operation.flowVersion,
|
||||
projectId: operation.projectId,
|
||||
engineToken: operation.engineToken,
|
||||
sampleData: operation.sampleData,
|
||||
})
|
||||
const { property, piece } = await pieceLoader.getPropOrThrow({ pieceName: operation.pieceName, pieceVersion: operation.pieceVersion, actionOrTriggerName: operation.actionOrTriggerName, propertyName: operation.propertyName, devPieces: EngineConstants.DEV_PIECES })
|
||||
|
||||
if (property.type !== PropertyType.DROPDOWN && property.type !== PropertyType.MULTI_SELECT_DROPDOWN && property.type !== PropertyType.DYNAMIC) {
|
||||
throw new EngineGenericError('PropertyTypeNotExecutableError', `Property type is not executable: ${property.type} for ${property.displayName}`)
|
||||
}
|
||||
const { data: executePropsResult, error: executePropsError } = await utils.tryCatchAndThrowOnEngineError((async (): Promise<ExecutePropsResult<PropertyType.DROPDOWN | PropertyType.MULTI_SELECT_DROPDOWN | PropertyType.DYNAMIC>> => {
|
||||
const { resolvedInput } = await createPropsResolver({
|
||||
apiUrl: constants.internalApiUrl,
|
||||
projectId: constants.projectId,
|
||||
engineToken: constants.engineToken,
|
||||
contextVersion: piece.getContextInfo?.().version,
|
||||
}).resolve<
|
||||
StaticPropsValue<PiecePropertyMap>
|
||||
>({
|
||||
unresolvedInput: operation.input,
|
||||
executionState,
|
||||
})
|
||||
const ctx = {
|
||||
searchValue: operation.searchValue,
|
||||
server: {
|
||||
token: constants.engineToken,
|
||||
apiUrl: constants.internalApiUrl,
|
||||
publicUrl: operation.publicApiUrl,
|
||||
},
|
||||
project: {
|
||||
id: constants.projectId,
|
||||
externalId: constants.externalProjectId,
|
||||
},
|
||||
flows: createFlowsContext(constants),
|
||||
step: {
|
||||
name: operation.actionOrTriggerName,
|
||||
},
|
||||
connections: utils.createConnectionManager({
|
||||
projectId: constants.projectId,
|
||||
engineToken: constants.engineToken,
|
||||
apiUrl: constants.internalApiUrl,
|
||||
target: 'properties',
|
||||
contextVersion: piece.getContextInfo?.().version,
|
||||
}),
|
||||
}
|
||||
|
||||
switch (property.type) {
|
||||
case PropertyType.DYNAMIC: {
|
||||
const dynamicProperty = property as DynamicProperties<boolean>
|
||||
const props = await dynamicProperty.props(resolvedInput, ctx)
|
||||
return {
|
||||
type: PropertyType.DYNAMIC,
|
||||
options: props,
|
||||
}
|
||||
}
|
||||
case PropertyType.MULTI_SELECT_DROPDOWN: {
|
||||
const multiSelectProperty = property as MultiSelectDropdownProperty<
|
||||
unknown,
|
||||
boolean
|
||||
>
|
||||
const options = await multiSelectProperty.options(resolvedInput, ctx)
|
||||
return {
|
||||
type: PropertyType.MULTI_SELECT_DROPDOWN,
|
||||
options,
|
||||
}
|
||||
}
|
||||
case PropertyType.DROPDOWN: {
|
||||
const dropdownProperty = property as DropdownProperty<unknown, boolean>
|
||||
const options = await dropdownProperty.options(resolvedInput, ctx)
|
||||
return {
|
||||
type: PropertyType.DROPDOWN,
|
||||
options,
|
||||
}
|
||||
}
|
||||
default: {
|
||||
throw new EngineGenericError('PropertyTypeNotExecutableError', `Property type is not executable: ${property}`)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
if (executePropsError) {
|
||||
console.error(executePropsError)
|
||||
return {
|
||||
type: property.type,
|
||||
options: {
|
||||
disabled: true,
|
||||
options: [],
|
||||
placeholder: 'Throws an error, reconnect or refresh the page',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return executePropsResult
|
||||
},
|
||||
|
||||
async executeValidateAuth(
|
||||
{ params, devPieces }: { params: ExecuteValidateAuthOperation, devPieces: string[] },
|
||||
): Promise<ExecuteValidateAuthResponse> {
|
||||
const { piece: piecePackage } = params
|
||||
|
||||
const piece = await pieceLoader.loadPieceOrThrow({ pieceName: piecePackage.pieceName, pieceVersion: piecePackage.pieceVersion, devPieces })
|
||||
const server = {
|
||||
apiUrl: params.internalApiUrl.endsWith('/') ? params.internalApiUrl : params.internalApiUrl + '/',
|
||||
publicUrl: params.publicApiUrl,
|
||||
}
|
||||
return validateAuth({
|
||||
authValue: params.auth,
|
||||
pieceAuth: piece.auth,
|
||||
server,
|
||||
})
|
||||
|
||||
},
|
||||
|
||||
async extractPieceMetadata({ devPieces, params }: { devPieces: string[], params: ExecuteExtractPieceMetadata }): Promise<PieceMetadata> {
|
||||
const { pieceName, pieceVersion } = params
|
||||
const piece = await pieceLoader.loadPieceOrThrow({ pieceName, pieceVersion, devPieces })
|
||||
const pieceAlias = pieceLoader.getPackageAlias({ pieceName, pieceVersion, devPieces })
|
||||
const pieceFolderPath = await pieceLoader.getPiecePath({ packageName: pieceAlias, devPieces })
|
||||
const i18n = await pieceTranslation.initializeI18n(pieceFolderPath)
|
||||
const fullMetadata = piece.metadata()
|
||||
return {
|
||||
...fullMetadata,
|
||||
name: pieceName,
|
||||
version: pieceVersion,
|
||||
authors: piece.authors,
|
||||
i18n,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
type ExecutePropsParams = Omit<ExecutePropsOptions, 'piece'> & { pieceName: string, pieceVersion: string }
|
||||
|
||||
|
||||
function mismatchAuthTypeErrorMessage(pieceAuthType: PropertyType, connectionType: AppConnectionType): ExecuteValidateAuthResponse {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Connection value type does not match piece auth type: ${pieceAuthType} !== ${connectionType}`,
|
||||
}
|
||||
}
|
||||
|
||||
const validateAuth = async ({
|
||||
server,
|
||||
authValue,
|
||||
pieceAuth,
|
||||
}: ValidateAuthParams): Promise<ExecuteValidateAuthResponse> => {
|
||||
if (isNil(pieceAuth)) {
|
||||
return {
|
||||
valid: true,
|
||||
}
|
||||
}
|
||||
const usedPieceAuth = getAuthPropertyForValue({
|
||||
authValueType: authValue.type,
|
||||
pieceAuth,
|
||||
})
|
||||
|
||||
if (isNil(usedPieceAuth)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'No piece auth found for auth value',
|
||||
}
|
||||
}
|
||||
if (isNil(usedPieceAuth.validate)) {
|
||||
return {
|
||||
valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
switch (usedPieceAuth.type) {
|
||||
case PropertyType.OAUTH2:{
|
||||
if (authValue.type !== AppConnectionType.OAUTH2 && authValue.type !== AppConnectionType.CLOUD_OAUTH2 && authValue.type !== AppConnectionType.PLATFORM_OAUTH2) {
|
||||
return mismatchAuthTypeErrorMessage(usedPieceAuth.type, authValue.type)
|
||||
}
|
||||
return usedPieceAuth.validate({
|
||||
auth: authValue,
|
||||
server,
|
||||
})
|
||||
}
|
||||
case PropertyType.BASIC_AUTH:{
|
||||
if (authValue.type !== AppConnectionType.BASIC_AUTH) {
|
||||
return mismatchAuthTypeErrorMessage(usedPieceAuth.type, authValue.type)
|
||||
}
|
||||
return usedPieceAuth.validate({
|
||||
auth: authValue,
|
||||
server,
|
||||
})
|
||||
}
|
||||
case PropertyType.SECRET_TEXT:{
|
||||
if (authValue.type !== AppConnectionType.SECRET_TEXT) {
|
||||
return mismatchAuthTypeErrorMessage(usedPieceAuth.type, authValue.type)
|
||||
}
|
||||
return usedPieceAuth.validate({
|
||||
auth: authValue.secret_text,
|
||||
server,
|
||||
})
|
||||
}
|
||||
case PropertyType.CUSTOM_AUTH:{
|
||||
if (authValue.type !== AppConnectionType.CUSTOM_AUTH) {
|
||||
return mismatchAuthTypeErrorMessage(usedPieceAuth.type, authValue.type)
|
||||
}
|
||||
return usedPieceAuth.validate({
|
||||
auth: authValue.props,
|
||||
server,
|
||||
})
|
||||
}
|
||||
default: {
|
||||
throw new EngineGenericError('InvalidAuthTypeError', 'Invalid auth type')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ValidateAuthParams = {
|
||||
server: {
|
||||
apiUrl: string
|
||||
publicUrl: string
|
||||
}
|
||||
authValue: AppConnectionValue
|
||||
pieceAuth: PieceAuthProperty | PieceAuthProperty[] | undefined
|
||||
}
|
||||
208
activepieces-fork/packages/engine/src/lib/helper/piece-loader.ts
Normal file
208
activepieces-fork/packages/engine/src/lib/helper/piece-loader.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { Action, Piece, PiecePropertyMap, Trigger } from '@activepieces/pieces-framework'
|
||||
import { ActivepiecesError, EngineGenericError, ErrorCode, extractPieceFromModule, getPackageAliasForPiece, getPieceNameFromAlias, isNil, trimVersionFromAlias } from '@activepieces/shared'
|
||||
import { utils } from '../utils'
|
||||
|
||||
export const pieceLoader = {
|
||||
loadPieceOrThrow: async (
|
||||
{ pieceName, pieceVersion, devPieces }: LoadPieceParams,
|
||||
): Promise<Piece> => {
|
||||
const { data: piece, error: pieceError } = await utils.tryCatchAndThrowOnEngineError(async () => {
|
||||
const packageName = pieceLoader.getPackageAlias({
|
||||
pieceName,
|
||||
pieceVersion,
|
||||
devPieces,
|
||||
})
|
||||
const piecePath = await pieceLoader.getPiecePath({ packageName, devPieces })
|
||||
const module = await import(piecePath)
|
||||
|
||||
const piece = extractPieceFromModule<Piece>({
|
||||
module,
|
||||
pieceName,
|
||||
pieceVersion,
|
||||
})
|
||||
|
||||
if (isNil(piece)) {
|
||||
throw new EngineGenericError('PieceNotFoundError', `Piece not found for piece: ${pieceName}, pieceVersion: ${pieceVersion}`)
|
||||
}
|
||||
return piece
|
||||
})
|
||||
if (pieceError) {
|
||||
throw pieceError
|
||||
}
|
||||
return piece
|
||||
},
|
||||
|
||||
getPieceAndTriggerOrThrow: async (params: GetPieceAndTriggerParams): Promise<{ piece: Piece, pieceTrigger: Trigger }> => {
|
||||
const { pieceName, pieceVersion, triggerName, devPieces } = params
|
||||
const piece = await pieceLoader.loadPieceOrThrow({ pieceName, pieceVersion, devPieces })
|
||||
const trigger = piece.getTrigger(triggerName)
|
||||
|
||||
if (trigger === undefined) {
|
||||
throw new EngineGenericError('TriggerNotFoundError', `Trigger not found, pieceName=${pieceName}, triggerName=${triggerName}`)
|
||||
}
|
||||
|
||||
return {
|
||||
piece,
|
||||
pieceTrigger: trigger,
|
||||
}
|
||||
},
|
||||
|
||||
getPieceAndActionOrThrow: async (params: GetPieceAndActionParams): Promise<{ piece: Piece, pieceAction: Action }> => {
|
||||
const { pieceName, pieceVersion, actionName, devPieces } = params
|
||||
|
||||
const piece = await pieceLoader.loadPieceOrThrow({ pieceName, pieceVersion, devPieces })
|
||||
const pieceAction = piece.getAction(actionName)
|
||||
|
||||
if (isNil(pieceAction)) {
|
||||
throw new ActivepiecesError({
|
||||
code: ErrorCode.STEP_NOT_FOUND,
|
||||
params: {
|
||||
pieceName,
|
||||
pieceVersion,
|
||||
stepName: actionName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
piece,
|
||||
pieceAction,
|
||||
}
|
||||
},
|
||||
|
||||
getPropOrThrow: async ({ pieceName, pieceVersion, actionOrTriggerName, propertyName, devPieces }: GetPropParams) => {
|
||||
const piece = await pieceLoader.loadPieceOrThrow({ pieceName, pieceVersion, devPieces })
|
||||
|
||||
const actionOrTrigger = piece.getAction(actionOrTriggerName) ?? piece.getTrigger(actionOrTriggerName)
|
||||
|
||||
if (isNil(actionOrTrigger)) {
|
||||
throw new ActivepiecesError({
|
||||
code: ErrorCode.STEP_NOT_FOUND,
|
||||
params: {
|
||||
pieceName,
|
||||
pieceVersion,
|
||||
stepName: actionOrTriggerName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const property = (actionOrTrigger.props as PiecePropertyMap)[propertyName]
|
||||
|
||||
if (isNil(property)) {
|
||||
throw new ActivepiecesError({
|
||||
code: ErrorCode.CONFIG_NOT_FOUND,
|
||||
params: {
|
||||
pieceName,
|
||||
pieceVersion,
|
||||
stepName: actionOrTriggerName,
|
||||
configName: propertyName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return { property, piece }
|
||||
},
|
||||
|
||||
getPackageAlias: ({ pieceName, pieceVersion, devPieces }: GetPackageAliasParams) => {
|
||||
if (devPieces.includes(getPieceNameFromAlias(pieceName))) {
|
||||
return pieceName
|
||||
}
|
||||
|
||||
return getPackageAliasForPiece({
|
||||
pieceName,
|
||||
pieceVersion,
|
||||
})
|
||||
},
|
||||
|
||||
getPiecePath: async ({ packageName, devPieces }: GetPiecePathParams): Promise<string> => {
|
||||
const piecePath = devPieces.includes(getPieceNameFromAlias(packageName))
|
||||
? await loadPieceFromDistFolder(packageName)
|
||||
: await traverseAllParentFoldersToFindPiece(packageName)
|
||||
if (isNil(piecePath)) {
|
||||
throw new EngineGenericError('PieceNotFoundError', `Piece not found for package: ${packageName}`)
|
||||
}
|
||||
return piecePath
|
||||
},
|
||||
}
|
||||
|
||||
async function loadPieceFromDistFolder(packageName: string): Promise<string | null> {
|
||||
const distPath = path.resolve('dist/packages/pieces')
|
||||
const entries = (await utils.walk(distPath)).filter((entry) => entry.name === 'package.json')
|
||||
for (const entry of entries) {
|
||||
const { data: packageJsonPath } = await utils.tryCatchAndThrowOnEngineError((async () => {
|
||||
const packageJsonPath = entry.path
|
||||
const packageJsonContent = await fs.readFile(packageJsonPath, 'utf-8')
|
||||
const packageJson = JSON.parse(packageJsonContent)
|
||||
if (packageJson.name === packageName) {
|
||||
return path.dirname(packageJsonPath)
|
||||
}
|
||||
return null
|
||||
}))
|
||||
if (packageJsonPath) {
|
||||
return packageJsonPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function traverseAllParentFoldersToFindPiece(packageName: string): Promise<string | null> {
|
||||
const rootDir = path.parse(__dirname).root
|
||||
let currentDir = __dirname
|
||||
const maxIterations = currentDir.split(path.sep).length
|
||||
for (let i = 0; i < maxIterations; i++) {
|
||||
const piecePath = path.resolve(currentDir, 'pieces', packageName, 'node_modules', trimVersionFromAlias(packageName))
|
||||
|
||||
if (await utils.folderExists(piecePath)) {
|
||||
return piecePath
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(currentDir)
|
||||
if (parentDir === currentDir || currentDir === rootDir) {
|
||||
break
|
||||
}
|
||||
currentDir = parentDir
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type GetPiecePathParams = {
|
||||
packageName: string
|
||||
devPieces: string[]
|
||||
}
|
||||
|
||||
type LoadPieceParams = {
|
||||
pieceName: string
|
||||
pieceVersion: string
|
||||
devPieces: string[]
|
||||
}
|
||||
|
||||
type GetPieceAndTriggerParams = {
|
||||
pieceName: string
|
||||
pieceVersion: string
|
||||
triggerName: string
|
||||
devPieces: string[]
|
||||
}
|
||||
|
||||
type GetPieceAndActionParams = {
|
||||
pieceName: string
|
||||
pieceVersion: string
|
||||
actionName: string
|
||||
devPieces: string[]
|
||||
}
|
||||
|
||||
type GetPropParams = {
|
||||
pieceName: string
|
||||
pieceVersion: string
|
||||
actionOrTriggerName: string
|
||||
propertyName: string
|
||||
devPieces: string[]
|
||||
}
|
||||
|
||||
type GetPackageAliasParams = {
|
||||
pieceName: string
|
||||
devPieces: string[]
|
||||
pieceVersion: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { inspect } from 'node:util'
|
||||
import { PiecePropertyMap, StaticPropsValue, TriggerStrategy } from '@activepieces/pieces-framework'
|
||||
import { assertEqual, AUTHENTICATION_PROPERTY_NAME, EngineGenericError, EventPayload, ExecuteTriggerOperation, ExecuteTriggerResponse, FlowTrigger, InvalidCronExpressionError, isNil, PieceTrigger, PropertySettings, ScheduleOptions, TriggerHookType, TriggerSourceScheduleType } from '@activepieces/shared'
|
||||
import { isValidCron } from 'cron-validator'
|
||||
import { EngineConstants } from '../handler/context/engine-constants'
|
||||
import { FlowExecutorContext } from '../handler/context/flow-execution-context'
|
||||
import { createFlowsContext } from '../services/flows.service'
|
||||
import { createFilesService } from '../services/step-files.service'
|
||||
import { createContextStore } from '../services/storage.service'
|
||||
import { utils } from '../utils'
|
||||
import { propsProcessor } from '../variables/props-processor'
|
||||
import { createPropsResolver } from '../variables/props-resolver'
|
||||
import { pieceLoader } from './piece-loader'
|
||||
|
||||
type Listener = {
|
||||
events: string[]
|
||||
identifierValue: string
|
||||
identifierKey: string
|
||||
}
|
||||
|
||||
export const triggerHelper = {
|
||||
async executeOnStart(trigger: FlowTrigger, constants: EngineConstants, payload: unknown) {
|
||||
const { pieceName, pieceVersion, triggerName, input, propertySettings } = (trigger as PieceTrigger).settings
|
||||
|
||||
if (isNil(triggerName)) {
|
||||
throw new EngineGenericError('TriggerNameNotSetError', 'Trigger name is not set')
|
||||
}
|
||||
|
||||
const { pieceTrigger, processedInput, piece } = await prepareTriggerExecution({
|
||||
pieceName,
|
||||
pieceVersion,
|
||||
triggerName,
|
||||
input,
|
||||
projectId: constants.projectId,
|
||||
apiUrl: constants.internalApiUrl,
|
||||
engineToken: constants.engineToken,
|
||||
devPieces: constants.devPieces,
|
||||
propertySettings,
|
||||
})
|
||||
const isOldVersionOrNotSupported = isNil(pieceTrigger.onStart)
|
||||
if (isOldVersionOrNotSupported) {
|
||||
return
|
||||
}
|
||||
const context = {
|
||||
store: createContextStore({
|
||||
apiUrl: constants.internalApiUrl,
|
||||
prefix: '',
|
||||
flowId: constants.flowId,
|
||||
engineToken: constants.engineToken,
|
||||
}),
|
||||
auth: processedInput[AUTHENTICATION_PROPERTY_NAME],
|
||||
propsValue: processedInput,
|
||||
payload,
|
||||
run: {
|
||||
id: constants.flowRunId,
|
||||
},
|
||||
step: {
|
||||
name: triggerName,
|
||||
},
|
||||
project: {
|
||||
id: constants.projectId,
|
||||
externalId: constants.externalProjectId,
|
||||
},
|
||||
connections: utils.createConnectionManager({
|
||||
apiUrl: constants.internalApiUrl,
|
||||
projectId: constants.projectId,
|
||||
engineToken: constants.engineToken,
|
||||
target: 'triggers',
|
||||
contextVersion: piece.getContextInfo?.().version,
|
||||
}),
|
||||
}
|
||||
await pieceTrigger.onStart(context)
|
||||
},
|
||||
|
||||
async executeTrigger({ params, constants }: ExecuteTriggerParams): Promise<ExecuteTriggerResponse<TriggerHookType>> {
|
||||
const { pieceName, pieceVersion, triggerName, input, propertySettings } = (params.flowVersion.trigger as PieceTrigger).settings
|
||||
|
||||
if (isNil(triggerName)) {
|
||||
throw new EngineGenericError('TriggerNameNotSetError', 'Trigger name is not set')
|
||||
}
|
||||
|
||||
const { piece, pieceTrigger, processedInput } = await prepareTriggerExecution({
|
||||
pieceName,
|
||||
pieceVersion,
|
||||
triggerName,
|
||||
input,
|
||||
projectId: params.projectId,
|
||||
apiUrl: constants.internalApiUrl,
|
||||
engineToken: params.engineToken,
|
||||
devPieces: constants.devPieces,
|
||||
propertySettings,
|
||||
})
|
||||
|
||||
const appListeners: Listener[] = []
|
||||
const prefix = params.test ? 'test' : ''
|
||||
let scheduleOptions: ScheduleOptions | undefined = undefined
|
||||
const context = {
|
||||
store: createContextStore({
|
||||
apiUrl: constants.internalApiUrl,
|
||||
prefix,
|
||||
flowId: params.flowVersion.flowId,
|
||||
engineToken: params.engineToken,
|
||||
}),
|
||||
step: {
|
||||
name: triggerName,
|
||||
},
|
||||
app: {
|
||||
createListeners({ events, identifierKey, identifierValue }: Listener): void {
|
||||
appListeners.push({ events, identifierValue, identifierKey })
|
||||
},
|
||||
},
|
||||
setSchedule(request: ScheduleOptions) {
|
||||
if (!isValidCron(request.cronExpression)) {
|
||||
throw new InvalidCronExpressionError(request.cronExpression)
|
||||
}
|
||||
scheduleOptions = {
|
||||
type: TriggerSourceScheduleType.CRON_EXPRESSION,
|
||||
cronExpression: request.cronExpression,
|
||||
timezone: request.timezone ?? 'UTC',
|
||||
}
|
||||
},
|
||||
flows: createFlowsContext({
|
||||
engineToken: params.engineToken,
|
||||
internalApiUrl: constants.internalApiUrl,
|
||||
flowId: params.flowVersion.flowId,
|
||||
flowVersionId: params.flowVersion.id,
|
||||
}),
|
||||
webhookUrl: params.webhookUrl,
|
||||
auth: processedInput[AUTHENTICATION_PROPERTY_NAME],
|
||||
propsValue: processedInput,
|
||||
payload: params.triggerPayload ?? {},
|
||||
project: {
|
||||
id: params.projectId,
|
||||
externalId: constants.externalProjectId,
|
||||
},
|
||||
server: {
|
||||
token: params.engineToken,
|
||||
apiUrl: constants.internalApiUrl,
|
||||
publicUrl: params.publicApiUrl,
|
||||
},
|
||||
connections: utils.createConnectionManager({
|
||||
apiUrl: constants.internalApiUrl,
|
||||
projectId: constants.projectId,
|
||||
engineToken: constants.engineToken,
|
||||
target: 'triggers',
|
||||
contextVersion: piece.getContextInfo?.().version,
|
||||
}),
|
||||
}
|
||||
switch (params.hookType) {
|
||||
case TriggerHookType.ON_DISABLE: {
|
||||
await pieceTrigger.onDisable(context)
|
||||
return {}
|
||||
}
|
||||
case TriggerHookType.ON_ENABLE: {
|
||||
await pieceTrigger.onEnable(context)
|
||||
return {
|
||||
listeners: appListeners,
|
||||
scheduleOptions: pieceTrigger.type === TriggerStrategy.POLLING ? scheduleOptions : undefined,
|
||||
}
|
||||
}
|
||||
case TriggerHookType.RENEW: {
|
||||
assertEqual(pieceTrigger.type, TriggerStrategy.WEBHOOK, 'triggerType', 'WEBHOOK')
|
||||
await pieceTrigger.onRenew(context)
|
||||
return {
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
case TriggerHookType.HANDSHAKE: {
|
||||
const { data: handshakeResponse, error: handshakeResponseError } = await utils.tryCatchAndThrowOnEngineError(() => pieceTrigger.onHandshake(context))
|
||||
|
||||
if (handshakeResponseError) {
|
||||
console.error(handshakeResponseError)
|
||||
return {
|
||||
success: false,
|
||||
message: `Error while testing trigger: ${inspect(handshakeResponseError)}`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
response: handshakeResponse,
|
||||
}
|
||||
}
|
||||
case TriggerHookType.TEST: {
|
||||
const { data: testResponse, error: testResponseError } = await utils.tryCatchAndThrowOnEngineError(() => pieceTrigger.test({
|
||||
...context,
|
||||
files: createFilesService({
|
||||
apiUrl: constants.internalApiUrl,
|
||||
engineToken: params.engineToken!,
|
||||
stepName: triggerName,
|
||||
flowId: params.flowVersion.flowId,
|
||||
}),
|
||||
}))
|
||||
|
||||
if (testResponseError) {
|
||||
console.error(testResponseError)
|
||||
return {
|
||||
success: false,
|
||||
message: `Error while testing trigger: ${inspect(testResponseError)}`,
|
||||
output: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: testResponse,
|
||||
}
|
||||
}
|
||||
case TriggerHookType.RUN: {
|
||||
if (pieceTrigger.type === TriggerStrategy.APP_WEBHOOK) {
|
||||
|
||||
const { data: verified, error: verifiedError } = await utils.tryCatchAndThrowOnEngineError(async () => {
|
||||
if (!params.appWebhookUrl) {
|
||||
throw new EngineGenericError('AppWebhookUrlNotAvailableError', `App webhook url is not available for piece name ${pieceName}`)
|
||||
}
|
||||
if (!params.webhookSecret) {
|
||||
throw new EngineGenericError('WebhookSecretNotAvailableError', `Webhook secret is not available for piece name ${pieceName}`)
|
||||
}
|
||||
|
||||
return piece.events?.verify({
|
||||
appWebhookUrl: params.appWebhookUrl,
|
||||
payload: params.triggerPayload as EventPayload,
|
||||
webhookSecret: params.webhookSecret,
|
||||
})
|
||||
})
|
||||
|
||||
if (verifiedError) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Error while verifying webhook: ${inspect(verifiedError)}`,
|
||||
output: [],
|
||||
}
|
||||
}
|
||||
if (isNil(verified)) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Webhook is not verified',
|
||||
output: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { data: triggerRunResult, error: triggerRunError } = await utils.tryCatchAndThrowOnEngineError(async () => {
|
||||
const items = await pieceTrigger.run({
|
||||
...context,
|
||||
files: createFilesService({
|
||||
apiUrl: constants.internalApiUrl,
|
||||
engineToken: params.engineToken!,
|
||||
flowId: params.flowVersion.flowId,
|
||||
stepName: triggerName,
|
||||
}),
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
output: items,
|
||||
}
|
||||
})
|
||||
|
||||
if (triggerRunError) {
|
||||
return {
|
||||
success: false,
|
||||
message: triggerRunError.message,
|
||||
output: [],
|
||||
}
|
||||
}
|
||||
return triggerRunResult
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
type ExecuteTriggerParams = {
|
||||
params: ExecuteTriggerOperation<TriggerHookType>
|
||||
constants: EngineConstants
|
||||
}
|
||||
|
||||
async function prepareTriggerExecution({ pieceName, pieceVersion, triggerName, input, propertySettings, projectId, apiUrl, engineToken, devPieces }: PrepareTriggerExecutionParams) {
|
||||
const { piece, pieceTrigger } = await pieceLoader.getPieceAndTriggerOrThrow({
|
||||
pieceName,
|
||||
pieceVersion,
|
||||
triggerName,
|
||||
devPieces,
|
||||
})
|
||||
|
||||
const { resolvedInput } = await createPropsResolver({
|
||||
apiUrl,
|
||||
projectId,
|
||||
engineToken,
|
||||
contextVersion: piece.getContextInfo?.().version,
|
||||
}).resolve<StaticPropsValue<PiecePropertyMap>>({
|
||||
unresolvedInput: input,
|
||||
executionState: FlowExecutorContext.empty(),
|
||||
})
|
||||
|
||||
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(resolvedInput, pieceTrigger.props, piece.auth, pieceTrigger.requireAuth, propertySettings)
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
throw new Error(JSON.stringify(errors, null, 2))
|
||||
}
|
||||
|
||||
return { piece, pieceTrigger, processedInput }
|
||||
}
|
||||
|
||||
type PrepareTriggerExecutionParams = {
|
||||
pieceName: string
|
||||
pieceVersion: string
|
||||
triggerName: string
|
||||
input: unknown
|
||||
propertySettings: Record<string, PropertySettings>
|
||||
projectId: string
|
||||
apiUrl: string
|
||||
engineToken: string
|
||||
devPieces: string[]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
EngineResponse,
|
||||
EngineResponseStatus,
|
||||
ExecuteValidateAuthOperation,
|
||||
ExecuteValidateAuthResponse,
|
||||
} from '@activepieces/shared'
|
||||
import { EngineConstants } from '../handler/context/engine-constants'
|
||||
import { pieceHelper } from '../helper/piece-helper'
|
||||
|
||||
export const authValidationOperation = {
|
||||
execute: async (operation: ExecuteValidateAuthOperation): Promise<EngineResponse<ExecuteValidateAuthResponse>> => {
|
||||
const input = operation as ExecuteValidateAuthOperation
|
||||
const output = await pieceHelper.executeValidateAuth({
|
||||
params: input,
|
||||
devPieces: EngineConstants.DEV_PIECES,
|
||||
})
|
||||
|
||||
return {
|
||||
status: EngineResponseStatus.OK,
|
||||
response: output,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
BeginExecuteFlowOperation,
|
||||
EngineResponse,
|
||||
EngineResponseStatus,
|
||||
ExecuteFlowOperation,
|
||||
ExecuteTriggerResponse,
|
||||
ExecutionType,
|
||||
FlowActionType,
|
||||
flowStructureUtil,
|
||||
GenericStepOutput,
|
||||
isNil,
|
||||
LoopStepOutput,
|
||||
StepOutput,
|
||||
StepOutputStatus,
|
||||
TriggerHookType,
|
||||
TriggerPayload,
|
||||
} from '@activepieces/shared'
|
||||
import { EngineConstants } from '../handler/context/engine-constants'
|
||||
import { FlowExecutorContext } from '../handler/context/flow-execution-context'
|
||||
import { testExecutionContext } from '../handler/context/test-execution-context'
|
||||
import { flowExecutor } from '../handler/flow-executor'
|
||||
import { triggerHelper } from '../helper/trigger-helper'
|
||||
import { progressService } from '../services/progress.service'
|
||||
|
||||
export const flowOperation = {
|
||||
execute: async (operation: ExecuteFlowOperation): Promise<EngineResponse<undefined>> => {
|
||||
const input = operation as ExecuteFlowOperation
|
||||
const constants = EngineConstants.fromExecuteFlowInput(input)
|
||||
const output: FlowExecutorContext = (await executieSingleStepOrFlowOperation(input)).finishExecution()
|
||||
await progressService.sendUpdate({
|
||||
engineConstants: constants,
|
||||
flowExecutorContext: output,
|
||||
updateImmediate: true,
|
||||
})
|
||||
return {
|
||||
status: EngineResponseStatus.OK,
|
||||
response: undefined,
|
||||
delayInSeconds: output.getDelayedInSeconds(),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const executieSingleStepOrFlowOperation = async (input: ExecuteFlowOperation): Promise<FlowExecutorContext> => {
|
||||
const constants = EngineConstants.fromExecuteFlowInput(input)
|
||||
const testSingleStepMode = !isNil(constants.stepNameToTest)
|
||||
if (testSingleStepMode) {
|
||||
const testContext = await testExecutionContext.stateFromFlowVersion({
|
||||
apiUrl: input.internalApiUrl,
|
||||
flowVersion: input.flowVersion,
|
||||
excludedStepName: input.stepNameToTest!,
|
||||
projectId: input.projectId,
|
||||
engineToken: input.engineToken,
|
||||
sampleData: input.sampleData,
|
||||
})
|
||||
const step = flowStructureUtil.getActionOrThrow(input.stepNameToTest!, input.flowVersion.trigger)
|
||||
return flowExecutor.execute({
|
||||
action: step,
|
||||
executionState: await getFlowExecutionState(input, testContext),
|
||||
constants: EngineConstants.fromExecuteFlowInput(input),
|
||||
})
|
||||
}
|
||||
return flowExecutor.executeFromTrigger({
|
||||
executionState: await getFlowExecutionState(input, FlowExecutorContext.empty()),
|
||||
constants,
|
||||
input,
|
||||
})
|
||||
}
|
||||
|
||||
async function getFlowExecutionState(input: ExecuteFlowOperation, flowContext: FlowExecutorContext): Promise<FlowExecutorContext> {
|
||||
switch (input.executionType) {
|
||||
case ExecutionType.BEGIN: {
|
||||
const newPayload = await runOrReturnPayload(input)
|
||||
flowContext = flowContext.upsertStep(input.flowVersion.trigger.name, GenericStepOutput.create({
|
||||
type: input.flowVersion.trigger.type,
|
||||
status: StepOutputStatus.SUCCEEDED,
|
||||
input: {},
|
||||
}).setOutput(newPayload))
|
||||
break
|
||||
}
|
||||
case ExecutionType.RESUME: {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for (const [step, output] of Object.entries(input.executionState.steps)) {
|
||||
if ([StepOutputStatus.SUCCEEDED, StepOutputStatus.PAUSED].includes(output.status)) {
|
||||
const newOutput = await insertSuccessStepsOrPausedRecursively(output)
|
||||
if (!isNil(newOutput)) {
|
||||
flowContext = flowContext.upsertStep(step, newOutput)
|
||||
}
|
||||
}
|
||||
}
|
||||
return flowContext
|
||||
}
|
||||
|
||||
async function runOrReturnPayload(input: BeginExecuteFlowOperation): Promise<TriggerPayload> {
|
||||
if (!input.executeTrigger) {
|
||||
return input.triggerPayload as TriggerPayload
|
||||
}
|
||||
const newPayload = await triggerHelper.executeTrigger({
|
||||
params: {
|
||||
...input,
|
||||
hookType: TriggerHookType.RUN,
|
||||
test: false,
|
||||
webhookUrl: '',
|
||||
triggerPayload: input.triggerPayload as TriggerPayload,
|
||||
},
|
||||
constants: EngineConstants.fromExecuteFlowInput(input),
|
||||
}) as ExecuteTriggerResponse<TriggerHookType.RUN>
|
||||
return newPayload.output[0] as TriggerPayload
|
||||
}
|
||||
|
||||
|
||||
async function insertSuccessStepsOrPausedRecursively(stepOutput: StepOutput): Promise<StepOutput | null> {
|
||||
if (![StepOutputStatus.SUCCEEDED, StepOutputStatus.PAUSED].includes(stepOutput.status)) {
|
||||
return null
|
||||
}
|
||||
if (stepOutput.type === FlowActionType.LOOP_ON_ITEMS) {
|
||||
const loopOutput = new LoopStepOutput(stepOutput)
|
||||
const iterations = loopOutput.output?.iterations ?? []
|
||||
const newIterations: Record<string, StepOutput>[] = []
|
||||
for (const iteration of iterations) {
|
||||
const newSteps: Record<string, StepOutput> = {}
|
||||
for (const [step, output] of Object.entries(iteration)) {
|
||||
const newOutput = await insertSuccessStepsOrPausedRecursively(output)
|
||||
if (!isNil(newOutput)) {
|
||||
newSteps[step] = newOutput
|
||||
}
|
||||
}
|
||||
newIterations.push(newSteps)
|
||||
}
|
||||
return loopOutput.setIterations(newIterations)
|
||||
}
|
||||
return stepOutput
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
EngineOperation,
|
||||
EngineOperationType,
|
||||
EngineResponse,
|
||||
ExecuteExtractPieceMetadataOperation,
|
||||
ExecuteFlowOperation,
|
||||
ExecutePropsOptions,
|
||||
ExecuteTriggerOperation,
|
||||
ExecuteValidateAuthOperation,
|
||||
ExecutionError,
|
||||
ExecutionErrorType,
|
||||
TriggerHookType,
|
||||
} from '@activepieces/shared'
|
||||
import { authValidationOperation } from './auth-validation.operation'
|
||||
import { flowOperation } from './flow.operation'
|
||||
import { pieceMetadataOperation } from './piece-metadata.operation'
|
||||
import { propertyOperation } from './property.operation'
|
||||
import { triggerHookOperation } from './trigger-hook.operation'
|
||||
|
||||
|
||||
export async function execute(operationType: EngineOperationType, operation: EngineOperation): Promise<EngineResponse<unknown>> {
|
||||
switch (operationType) {
|
||||
case EngineOperationType.EXTRACT_PIECE_METADATA: {
|
||||
return pieceMetadataOperation.extract(operation as ExecuteExtractPieceMetadataOperation)
|
||||
}
|
||||
case EngineOperationType.EXECUTE_FLOW: {
|
||||
return flowOperation.execute(operation as ExecuteFlowOperation)
|
||||
}
|
||||
case EngineOperationType.EXECUTE_PROPERTY: {
|
||||
return propertyOperation.execute(operation as ExecutePropsOptions)
|
||||
}
|
||||
case EngineOperationType.EXECUTE_TRIGGER_HOOK: {
|
||||
return triggerHookOperation.execute(operation as ExecuteTriggerOperation<TriggerHookType>)
|
||||
}
|
||||
case EngineOperationType.EXECUTE_VALIDATE_AUTH: {
|
||||
return authValidationOperation.execute(operation as ExecuteValidateAuthOperation)
|
||||
}
|
||||
default: {
|
||||
throw new ExecutionError('Unsupported operation type', `Unsupported operation type: ${operationType}`, ExecutionErrorType.ENGINE)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PieceMetadata } from '@activepieces/pieces-framework'
|
||||
import {
|
||||
EngineResponse,
|
||||
EngineResponseStatus,
|
||||
ExecuteExtractPieceMetadataOperation,
|
||||
} from '@activepieces/shared'
|
||||
import { EngineConstants } from '../handler/context/engine-constants'
|
||||
import { pieceHelper } from '../helper/piece-helper'
|
||||
|
||||
|
||||
export const pieceMetadataOperation = {
|
||||
extract: async (operation: ExecuteExtractPieceMetadataOperation): Promise<EngineResponse<PieceMetadata>> => {
|
||||
const input = operation as ExecuteExtractPieceMetadataOperation
|
||||
const output = await pieceHelper.extractPieceMetadata({
|
||||
params: input,
|
||||
devPieces: EngineConstants.DEV_PIECES,
|
||||
})
|
||||
return {
|
||||
status: EngineResponseStatus.OK,
|
||||
response: output,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ExecutePropsResult, PropertyType } from '@activepieces/pieces-framework'
|
||||
import {
|
||||
EngineResponse,
|
||||
EngineResponseStatus,
|
||||
ExecutePropsOptions,
|
||||
} from '@activepieces/shared'
|
||||
import { pieceHelper } from '../helper/piece-helper'
|
||||
|
||||
|
||||
export const propertyOperation = {
|
||||
execute: async (operation: ExecutePropsOptions): Promise<EngineResponse<ExecutePropsResult<PropertyType.DROPDOWN | PropertyType.MULTI_SELECT_DROPDOWN | PropertyType.DYNAMIC>>> => {
|
||||
const output = await pieceHelper.executeProps({
|
||||
...operation,
|
||||
pieceName: operation.piece.pieceName,
|
||||
pieceVersion: operation.piece.pieceVersion,
|
||||
})
|
||||
return {
|
||||
status: EngineResponseStatus.OK,
|
||||
response: output,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
EngineResponse,
|
||||
EngineResponseStatus,
|
||||
ExecuteTriggerOperation,
|
||||
ExecuteTriggerResponse,
|
||||
TriggerHookType,
|
||||
} from '@activepieces/shared'
|
||||
import { EngineConstants } from '../handler/context/engine-constants'
|
||||
import { triggerHelper } from '../helper/trigger-helper'
|
||||
|
||||
|
||||
export const triggerHookOperation = {
|
||||
execute: async (operation: ExecuteTriggerOperation<TriggerHookType>): Promise<EngineResponse<ExecuteTriggerResponse<TriggerHookType>>> => {
|
||||
const input = operation as ExecuteTriggerOperation<TriggerHookType>
|
||||
const output = await triggerHelper.executeTrigger({
|
||||
params: input,
|
||||
constants: EngineConstants.fromExecuteTriggerInput(input),
|
||||
})
|
||||
return {
|
||||
status: EngineResponseStatus.OK,
|
||||
response: output,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ContextVersion } from '@activepieces/pieces-framework'
|
||||
import { AppConnection, AppConnectionStatus, AppConnectionType, AppConnectionValue, ConnectionExpiredError, ConnectionLoadingError, ConnectionNotFoundError, ExecutionError, FetchError } from '@activepieces/shared'
|
||||
import { StatusCodes } from 'http-status-codes'
|
||||
import { utils } from '../utils'
|
||||
|
||||
export const createConnectionService = ({ projectId, engineToken, apiUrl, contextVersion }: CreateConnectionServiceParams): ConnectionService => {
|
||||
return {
|
||||
async obtain(externalId: string): Promise<AppConnectionValue> {
|
||||
const url = `${apiUrl}v1/worker/app-connections/${encodeURIComponent(externalId)}?projectId=${projectId}`
|
||||
|
||||
const { data: connectionValue, error: connectionValueError } = await utils.tryCatchAndThrowOnEngineError((async () => {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${engineToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return handleResponseError({
|
||||
externalId,
|
||||
httpStatus: response.status,
|
||||
})
|
||||
}
|
||||
const connection: AppConnection = await response.json()
|
||||
if (connection.status === AppConnectionStatus.ERROR) {
|
||||
throw new ConnectionExpiredError(externalId)
|
||||
}
|
||||
return getConnectionValue(connection, contextVersion)
|
||||
}))
|
||||
|
||||
if (connectionValueError) {
|
||||
if (connectionValueError instanceof ExecutionError) {
|
||||
throw connectionValueError
|
||||
}
|
||||
return handleFetchError({
|
||||
url,
|
||||
cause: connectionValueError,
|
||||
})
|
||||
}
|
||||
return connectionValue
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const handleResponseError = ({ externalId, httpStatus }: HandleResponseErrorParams): never => {
|
||||
if (httpStatus === StatusCodes.NOT_FOUND.valueOf()) {
|
||||
throw new ConnectionNotFoundError(externalId)
|
||||
}
|
||||
|
||||
throw new ConnectionLoadingError(externalId)
|
||||
}
|
||||
|
||||
const handleFetchError = ({ url, cause }: HandleFetchErrorParams): never => {
|
||||
throw new FetchError(url, cause)
|
||||
}
|
||||
|
||||
const getConnectionValue = (connection: AppConnection, contextVersion: ContextVersion | undefined): AppConnectionValue => {
|
||||
switch (contextVersion) {
|
||||
case undefined:
|
||||
return makeConnectionValueCompatibleWithContextV0(connection)
|
||||
case ContextVersion.V1:
|
||||
return connection.value
|
||||
default:
|
||||
return connection.value
|
||||
}
|
||||
}
|
||||
|
||||
function makeConnectionValueCompatibleWithContextV0(connection: AppConnection): AppConnectionValue {
|
||||
switch (connection.value.type) {
|
||||
case AppConnectionType.SECRET_TEXT:
|
||||
return connection.value.secret_text as unknown as AppConnectionValue
|
||||
|
||||
case AppConnectionType.CUSTOM_AUTH:
|
||||
return connection.value.props as unknown as AppConnectionValue
|
||||
default:
|
||||
return connection.value as unknown as AppConnectionValue
|
||||
}
|
||||
}
|
||||
type ConnectionService = {
|
||||
obtain(externalId: string): Promise<AppConnectionValue>
|
||||
}
|
||||
|
||||
type CreateConnectionServiceParams = {
|
||||
projectId: string
|
||||
apiUrl: string
|
||||
engineToken: string
|
||||
contextVersion: ContextVersion | undefined
|
||||
}
|
||||
|
||||
type HandleResponseErrorParams = {
|
||||
externalId: string
|
||||
httpStatus: number
|
||||
}
|
||||
|
||||
type HandleFetchErrorParams = {
|
||||
url: string
|
||||
cause: unknown
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { FlowsContext, ListFlowsContextParams } from '@activepieces/pieces-framework'
|
||||
import { PopulatedFlow, SeekPage } from '@activepieces/shared'
|
||||
|
||||
|
||||
type CreateFlowsServiceParams = {
|
||||
engineToken: string
|
||||
internalApiUrl: string
|
||||
flowId: string
|
||||
flowVersionId: string
|
||||
}
|
||||
export const createFlowsContext = ({ engineToken, internalApiUrl, flowId, flowVersionId }: CreateFlowsServiceParams): FlowsContext => {
|
||||
return {
|
||||
async list(params: ListFlowsContextParams): Promise<SeekPage<PopulatedFlow>> {
|
||||
const queryParams = new URLSearchParams()
|
||||
if (params?.externalIds) {
|
||||
queryParams.set('externalIds', params.externalIds.join(','))
|
||||
}
|
||||
const response = await fetch(`${internalApiUrl}v1/engine/populated-flows?${queryParams.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${engineToken}`,
|
||||
},
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
current: {
|
||||
id: flowId,
|
||||
version: {
|
||||
id: flowVersionId,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { OutputContext } from '@activepieces/pieces-framework'
|
||||
import { DEFAULT_MCP_DATA, EngineGenericError, EngineSocketEvent, FlowActionType, FlowRunStatus, GenericStepOutput, isFlowRunStateTerminal, isNil, logSerializer, StepOutput, StepOutputStatus, StepRunResponse, UpdateRunProgressRequest } from '@activepieces/shared'
|
||||
import { Mutex } from 'async-mutex'
|
||||
import dayjs from 'dayjs'
|
||||
import fetchRetry from 'fetch-retry'
|
||||
import { EngineConstants } from '../handler/context/engine-constants'
|
||||
import { FlowExecutorContext } from '../handler/context/flow-execution-context'
|
||||
import { utils } from '../utils'
|
||||
import { workerSocket } from '../worker-socket'
|
||||
|
||||
|
||||
let lastScheduledUpdateId: NodeJS.Timeout | null = null
|
||||
let lastActionExecutionTime: number | undefined = undefined
|
||||
let isGraceShutdownSignalReceived = false
|
||||
const MAXIMUM_UPDATE_THRESHOLD = 15000
|
||||
const DEBOUNCE_THRESHOLD = 5000
|
||||
const lock = new Mutex()
|
||||
const updateLock = new Mutex()
|
||||
const fetchWithRetry = fetchRetry(global.fetch)
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
isGraceShutdownSignalReceived = true
|
||||
})
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
isGraceShutdownSignalReceived = true
|
||||
})
|
||||
|
||||
export const progressService = {
|
||||
sendUpdate: async (params: UpdateStepProgressParams): Promise<void> => {
|
||||
return updateLock.runExclusive(async () => {
|
||||
if (lastScheduledUpdateId) {
|
||||
clearTimeout(lastScheduledUpdateId)
|
||||
}
|
||||
|
||||
const shouldUpdateNow = isNil(lastActionExecutionTime) || (Date.now() - lastActionExecutionTime > MAXIMUM_UPDATE_THRESHOLD) || isGraceShutdownSignalReceived
|
||||
if (shouldUpdateNow || params.updateImmediate) {
|
||||
await sendUpdateRunRequest(params)
|
||||
return
|
||||
}
|
||||
|
||||
lastScheduledUpdateId = setTimeout(async () => {
|
||||
await sendUpdateRunRequest(params)
|
||||
}, DEBOUNCE_THRESHOLD)
|
||||
})
|
||||
},
|
||||
createOutputContext: (params: CreateOutputContextParams): OutputContext => {
|
||||
const { engineConstants, flowExecutorContext, stepName, stepOutput } = params
|
||||
return {
|
||||
update: async (params: { data: unknown }) => {
|
||||
const trimmedSteps = await flowExecutorContext
|
||||
.upsertStep(stepName, stepOutput.setOutput(params.data))
|
||||
.trimmedSteps()
|
||||
const stepResponse = extractStepResponse({
|
||||
steps: trimmedSteps,
|
||||
runId: engineConstants.flowRunId,
|
||||
stepName,
|
||||
})
|
||||
await workerSocket.sendToWorkerWithAck(EngineSocketEvent.UPDATE_STEP_PROGRESS, {
|
||||
projectId: engineConstants.projectId,
|
||||
stepResponse,
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
type CreateOutputContextParams = {
|
||||
engineConstants: EngineConstants
|
||||
flowExecutorContext: FlowExecutorContext
|
||||
stepName: string
|
||||
stepOutput: GenericStepOutput<FlowActionType.PIECE, unknown>
|
||||
}
|
||||
|
||||
const queueUpdates: UpdateStepProgressParams[] = []
|
||||
|
||||
const sendUpdateRunRequest = async (updateParams: UpdateStepProgressParams): Promise<void> => {
|
||||
const isRunningMcp = updateParams.engineConstants.flowRunId === DEFAULT_MCP_DATA.flowRunId
|
||||
if (updateParams.engineConstants.isRunningApTests || isRunningMcp) {
|
||||
return
|
||||
}
|
||||
queueUpdates.push(updateParams)
|
||||
await lock.runExclusive(async () => {
|
||||
const params = queueUpdates.pop()
|
||||
while (queueUpdates.length > 0) {
|
||||
queueUpdates.pop()
|
||||
}
|
||||
if (isNil(params)) {
|
||||
return
|
||||
}
|
||||
lastActionExecutionTime = Date.now()
|
||||
const { flowExecutorContext, engineConstants } = params
|
||||
const trimmedSteps = await flowExecutorContext.trimmedSteps()
|
||||
const executionState = await logSerializer.serialize({
|
||||
executionState: {
|
||||
steps: trimmedSteps,
|
||||
},
|
||||
})
|
||||
if (isNil(engineConstants.logsUploadUrl)) {
|
||||
throw new EngineGenericError('LogsUploadUrlNotSetError', 'Logs upload URL is not set')
|
||||
}
|
||||
const uploadLogResponse = await uploadExecutionState(engineConstants.logsUploadUrl, executionState)
|
||||
if (!uploadLogResponse.ok) {
|
||||
throw new EngineGenericError('ProgressUpdateError', 'Failed to upload execution state', uploadLogResponse)
|
||||
}
|
||||
|
||||
const stepResponse = extractStepResponse({
|
||||
steps: trimmedSteps,
|
||||
runId: engineConstants.flowRunId,
|
||||
stepName: engineConstants.stepNameToTest,
|
||||
})
|
||||
|
||||
const request: UpdateRunProgressRequest = {
|
||||
runId: engineConstants.flowRunId,
|
||||
projectId: engineConstants.projectId,
|
||||
workerHandlerId: engineConstants.serverHandlerId ?? null,
|
||||
httpRequestId: engineConstants.httpRequestId ?? null,
|
||||
status: flowExecutorContext.verdict.status,
|
||||
progressUpdateType: engineConstants.progressUpdateType,
|
||||
logsFileId: engineConstants.logsFileId,
|
||||
failedStep: flowExecutorContext.verdict.status === FlowRunStatus.FAILED ? flowExecutorContext.verdict.failedStep : undefined,
|
||||
stepNameToTest: engineConstants.stepNameToTest,
|
||||
stepResponse,
|
||||
pauseMetadata: flowExecutorContext.verdict.status === FlowRunStatus.PAUSED ? flowExecutorContext.verdict.pauseMetadata : undefined,
|
||||
finishTime: isFlowRunStateTerminal({
|
||||
status: flowExecutorContext.verdict.status,
|
||||
ignoreInternalError: false,
|
||||
}) ? dayjs().toISOString() : undefined,
|
||||
tags: Array.from(flowExecutorContext.tags),
|
||||
stepsCount: flowExecutorContext.stepsCount,
|
||||
}
|
||||
|
||||
|
||||
await sendProgressUpdate(request)
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
const sendProgressUpdate = async (request: UpdateRunProgressRequest): Promise<void> => {
|
||||
const result = await utils.tryCatchAndThrowOnEngineError(() =>
|
||||
workerSocket.sendToWorkerWithAck(EngineSocketEvent.UPDATE_RUN_PROGRESS, request),
|
||||
)
|
||||
if (result.error) {
|
||||
throw new EngineGenericError('ProgressUpdateError', 'Failed to send progress update', result.error)
|
||||
}
|
||||
}
|
||||
|
||||
const uploadExecutionState = async (uploadUrl: string, executionState: Buffer, followRedirects = true): Promise<Response> => {
|
||||
const response = await fetchWithRetry(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: new Uint8Array(executionState),
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
redirect: 'manual',
|
||||
retries: 3,
|
||||
retryDelay: 3000,
|
||||
})
|
||||
|
||||
if (followRedirects && response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('location')!
|
||||
return uploadExecutionState(location, executionState, false)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
|
||||
const extractStepResponse = (params: ExtractStepResponse): StepRunResponse | undefined => {
|
||||
if (isNil(params.stepName)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const stepOutput = params.steps?.[params.stepName]
|
||||
const isSuccess = stepOutput?.status === StepOutputStatus.SUCCEEDED || stepOutput?.status === StepOutputStatus.PAUSED
|
||||
return {
|
||||
runId: params.runId,
|
||||
success: isSuccess,
|
||||
input: stepOutput?.input,
|
||||
output: stepOutput?.output,
|
||||
standardError: isSuccess ? '' : (stepOutput?.errorMessage as string),
|
||||
standardOutput: '',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
type UpdateStepProgressParams = {
|
||||
engineConstants: EngineConstants
|
||||
flowExecutorContext: FlowExecutorContext
|
||||
updateImmediate?: boolean
|
||||
}
|
||||
|
||||
type ExtractStepResponse = {
|
||||
steps: Record<string, StepOutput>
|
||||
runId: string
|
||||
stepName?: string
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { FilesService } from '@activepieces/pieces-framework'
|
||||
import { FileLocation, FileSizeError, FileStoreError, isNil, StepFileUpsertResponse } from '@activepieces/shared'
|
||||
import fetchRetry from 'fetch-retry'
|
||||
|
||||
const MAX_FILE_SIZE_MB = Number(process.env.AP_MAX_FILE_SIZE_MB)
|
||||
const FILE_STORAGE_LOCATION = process.env.AP_FILE_STORAGE_LOCATION as FileLocation
|
||||
const USE_SIGNED_URL = (process.env.AP_S3_USE_SIGNED_URLS === 'true') && FILE_STORAGE_LOCATION === FileLocation.S3
|
||||
|
||||
export type DefaultFileSystem = 'db' | 'local'
|
||||
|
||||
type CreateFilesServiceParams = { apiUrl: string, stepName: string, flowId: string, engineToken: string }
|
||||
|
||||
export function createFilesService({ stepName, flowId, engineToken, apiUrl }: CreateFilesServiceParams): FilesService {
|
||||
return {
|
||||
write: async ({ fileName, data }: { fileName: string, data: Buffer }): Promise<string> => {
|
||||
validateFileSize(data)
|
||||
const formData = createFormData({ fileName, data, stepName, flowId })
|
||||
const result = await uploadFileMetadata({ formData, engineToken, apiUrl })
|
||||
if (USE_SIGNED_URL) {
|
||||
if (isNil(result.uploadUrl)) {
|
||||
throw new FileStoreError({
|
||||
status: 500,
|
||||
body: 'Upload URL is not available',
|
||||
})
|
||||
}
|
||||
await uploadFileContent({ url: result.uploadUrl, data })
|
||||
}
|
||||
|
||||
return result.url
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function validateFileSize(data: Buffer): void {
|
||||
const maximumFileSizeInBytes = MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
if (data.length > maximumFileSizeInBytes) {
|
||||
throw new FileSizeError(data.length / 1024 / 1024, MAX_FILE_SIZE_MB)
|
||||
}
|
||||
}
|
||||
|
||||
function createFormData({ fileName, data, stepName, flowId }: { fileName: string, data: Buffer, stepName: string, flowId: string }): FormData {
|
||||
const formData = new FormData()
|
||||
formData.append('stepName', stepName)
|
||||
formData.append('flowId', flowId)
|
||||
formData.append('contentLength', data.length.toString())
|
||||
formData.append('fileName', fileName)
|
||||
|
||||
if (!USE_SIGNED_URL) {
|
||||
formData.append('file', new Blob([data], { type: 'application/octet-stream' }), fileName)
|
||||
}
|
||||
|
||||
return formData
|
||||
}
|
||||
|
||||
async function uploadFileMetadata({ formData, engineToken, apiUrl }: { formData: FormData, engineToken: string, apiUrl: string }): Promise<StepFileUpsertResponse> {
|
||||
const fetchWithRetry = fetchRetry(global.fetch)
|
||||
const response = await fetchWithRetry(apiUrl + 'v1/step-files', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + engineToken,
|
||||
},
|
||||
retryDelay: 3000,
|
||||
retries: 3,
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new FileStoreError({
|
||||
status: response.status,
|
||||
body: response.body,
|
||||
})
|
||||
}
|
||||
|
||||
return await response.json() as StepFileUpsertResponse
|
||||
}
|
||||
|
||||
async function uploadFileContent({ url, data }: { url: string, data: Buffer }): Promise<void> {
|
||||
const fetchWithRetry = fetchRetry(global.fetch)
|
||||
const uploadResponse = await fetchWithRetry(url, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
retries: 3,
|
||||
retryDelay: 3000,
|
||||
})
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new FileStoreError({
|
||||
status: uploadResponse.status,
|
||||
body: uploadResponse.body,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { URL } from 'node:url'
|
||||
import { Store, StoreScope } from '@activepieces/pieces-framework'
|
||||
import { DeleteStoreEntryRequest, ExecutionError, FetchError, FlowId, isNil, PutStoreEntryRequest, StorageError, StorageInvalidKeyError, StorageLimitError, STORE_KEY_MAX_LENGTH, STORE_VALUE_MAX_SIZE, StoreEntry } from '@activepieces/shared'
|
||||
import { StatusCodes } from 'http-status-codes'
|
||||
import sizeof from 'object-sizeof'
|
||||
import { utils } from '../utils'
|
||||
|
||||
export const createStorageService = ({ engineToken, apiUrl }: CreateStorageServiceParams): StorageService => {
|
||||
return {
|
||||
async get(key: string): Promise<StoreEntry | null> {
|
||||
const url = buildUrl(apiUrl, key)
|
||||
|
||||
const { data: storeEntry, error: storeEntryError } = await utils.tryCatchAndThrowOnEngineError((async () => {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${engineToken}`,
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
return handleResponseError({
|
||||
key,
|
||||
response,
|
||||
})
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}))
|
||||
|
||||
if (storeEntryError) {
|
||||
return handleFetchError({
|
||||
url,
|
||||
cause: storeEntryError,
|
||||
})
|
||||
}
|
||||
return storeEntry
|
||||
},
|
||||
|
||||
async put(request: PutStoreEntryRequest): Promise<StoreEntry | null> {
|
||||
const url = buildUrl(apiUrl)
|
||||
|
||||
const { data: storeEntry, error: storeEntryError } = await utils.tryCatchAndThrowOnEngineError((async () => {
|
||||
const sizeOfValue = sizeof(request.value)
|
||||
if (sizeOfValue > STORE_VALUE_MAX_SIZE) {
|
||||
throw new StorageLimitError(request.key, STORE_VALUE_MAX_SIZE)
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${engineToken}`,
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return handleResponseError({
|
||||
key: request.key,
|
||||
response,
|
||||
})
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}))
|
||||
|
||||
if (storeEntryError) {
|
||||
return handleFetchError({
|
||||
url,
|
||||
cause: storeEntryError,
|
||||
})
|
||||
}
|
||||
return storeEntry
|
||||
},
|
||||
|
||||
async delete(request: DeleteStoreEntryRequest): Promise<null> {
|
||||
const url = buildUrl(apiUrl, request.key)
|
||||
|
||||
const { data: storeEntry, error: storeEntryError } = await utils.tryCatchAndThrowOnEngineError((async () => {
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${engineToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
await handleResponseError({
|
||||
key: request.key,
|
||||
response,
|
||||
})
|
||||
}
|
||||
|
||||
return null
|
||||
}))
|
||||
|
||||
if (storeEntryError) {
|
||||
return handleFetchError({
|
||||
url,
|
||||
cause: storeEntryError,
|
||||
})
|
||||
}
|
||||
return storeEntry
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createContextStore({ apiUrl, prefix, flowId, engineToken }: { apiUrl: string, prefix: string, flowId: FlowId, engineToken: string }): Store {
|
||||
return {
|
||||
async put<T>(key: string, value: T, scope = StoreScope.FLOW): Promise<T> {
|
||||
const modifiedKey = createKey(prefix, scope, flowId, key)
|
||||
await createStorageService({ apiUrl, engineToken }).put({
|
||||
key: modifiedKey,
|
||||
value,
|
||||
})
|
||||
return value
|
||||
},
|
||||
async delete(key: string, scope = StoreScope.FLOW): Promise<void> {
|
||||
const modifiedKey = createKey(prefix, scope, flowId, key)
|
||||
await createStorageService({ apiUrl, engineToken }).delete({
|
||||
key: modifiedKey,
|
||||
})
|
||||
},
|
||||
async get<T>(key: string, scope = StoreScope.FLOW): Promise<T | null> {
|
||||
const modifiedKey = createKey(prefix, scope, flowId, key)
|
||||
const storeEntry = await createStorageService({ apiUrl, engineToken }).get(modifiedKey)
|
||||
if (storeEntry === null) {
|
||||
return null
|
||||
}
|
||||
return storeEntry.value as T
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createKey(prefix: string, scope: StoreScope, flowId: FlowId, key: string): string {
|
||||
if (isNil(key) || typeof key !== 'string' || key.length === 0 || key.length > STORE_KEY_MAX_LENGTH) {
|
||||
throw new StorageInvalidKeyError(key)
|
||||
}
|
||||
switch (scope) {
|
||||
case StoreScope.PROJECT:
|
||||
return prefix + key
|
||||
case StoreScope.FLOW:
|
||||
return prefix + 'flow_' + flowId + '/' + key
|
||||
}
|
||||
}
|
||||
|
||||
const buildUrl = (apiUrl: string, key?: string): URL => {
|
||||
const url = new URL(`${apiUrl}v1/store-entries`)
|
||||
if (key) {
|
||||
url.searchParams.set('key', key)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
const handleResponseError = async ({ key, response }: HandleResponseErrorParams): Promise<null> => {
|
||||
if (response.status === StatusCodes.NOT_FOUND.valueOf()) {
|
||||
return null
|
||||
}
|
||||
if (response.status === StatusCodes.REQUEST_TOO_LONG) {
|
||||
throw new StorageLimitError(key, STORE_VALUE_MAX_SIZE)
|
||||
}
|
||||
const cause = await response.text()
|
||||
throw new StorageError(key, cause)
|
||||
}
|
||||
|
||||
const handleFetchError = ({ url, cause }: HandleFetchErrorParams): never => {
|
||||
if (cause instanceof ExecutionError) {
|
||||
throw cause
|
||||
}
|
||||
throw new FetchError(url.toString(), cause)
|
||||
}
|
||||
|
||||
type CreateStorageServiceParams = {
|
||||
engineToken: string
|
||||
apiUrl: string
|
||||
}
|
||||
|
||||
type StorageService = {
|
||||
get(key: string): Promise<StoreEntry | null>
|
||||
put(request: PutStoreEntryRequest): Promise<StoreEntry | null>
|
||||
delete(request: DeleteStoreEntryRequest): Promise<null>
|
||||
}
|
||||
|
||||
type HandleResponseErrorParams = {
|
||||
key: string
|
||||
response: Response
|
||||
}
|
||||
|
||||
type HandleFetchErrorParams = {
|
||||
url: URL
|
||||
cause: unknown
|
||||
}
|
||||
260
activepieces-fork/packages/engine/src/lib/tools/index.ts
Normal file
260
activepieces-fork/packages/engine/src/lib/tools/index.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { Action, DropdownOption, ExecutePropsResult, PieceProperty, PropertyType } from '@activepieces/pieces-framework'
|
||||
import { AgentTool, AgentToolType, ExecuteToolOperation, ExecuteToolResponse, ExecutionToolStatus, FlowActionType, isNil, PieceAction, PropertyExecutionType, StepOutputStatus } from '@activepieces/shared'
|
||||
import { generateObject, LanguageModel, ToolSet } from 'ai'
|
||||
import { z } from 'zod/v4'
|
||||
import { EngineConstants } from '../handler/context/engine-constants'
|
||||
import { FlowExecutorContext } from '../handler/context/flow-execution-context'
|
||||
import { flowExecutor } from '../handler/flow-executor'
|
||||
import { pieceHelper } from '../helper/piece-helper'
|
||||
import { pieceLoader } from '../helper/piece-loader'
|
||||
import { tsort } from './tsort'
|
||||
|
||||
export const agentTools = {
|
||||
async tools({ engineConstants, tools, model }: ConstructToolParams): Promise<ToolSet> {
|
||||
const piecesTools = await Promise.all(tools
|
||||
.filter((tool) => tool.type === AgentToolType.PIECE)
|
||||
.map(async (tool) => {
|
||||
const { pieceAction } = await pieceLoader.getPieceAndActionOrThrow({
|
||||
pieceName: tool.pieceMetadata.pieceName,
|
||||
pieceVersion: tool.pieceMetadata.pieceVersion,
|
||||
actionName: tool.pieceMetadata.actionName,
|
||||
devPieces: EngineConstants.DEV_PIECES,
|
||||
})
|
||||
return {
|
||||
name: tool.toolName,
|
||||
description: pieceAction.description,
|
||||
inputSchema: z.object({
|
||||
instruction: z.string().describe('The instruction to the tool'),
|
||||
}),
|
||||
execute: async ({ instruction }: { instruction: string }) =>
|
||||
execute({
|
||||
...engineConstants,
|
||||
instruction,
|
||||
pieceName: tool.pieceMetadata.pieceName,
|
||||
pieceVersion: tool.pieceMetadata.pieceVersion,
|
||||
actionName: tool.pieceMetadata.actionName,
|
||||
predefinedInput: tool.pieceMetadata.predefinedInput,
|
||||
model,
|
||||
}),
|
||||
}
|
||||
}))
|
||||
|
||||
return {
|
||||
...Object.fromEntries(piecesTools.map((tool) => [tool.name, tool])),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async function resolveProperties(depthToPropertyMap: Record<number, string[]>, instruction: string, action: Action, model: LanguageModel, operation: ExecuteToolOperation): Promise<Record<string, unknown>> {
|
||||
let result: Record<string, unknown> = operation.predefinedInput
|
||||
for (const [_, properties] of Object.entries(depthToPropertyMap)) {
|
||||
const propertyToFill: Record<string, z.ZodTypeAny> = {}
|
||||
const propertyPrompts: string[] = []
|
||||
for (const property of properties) {
|
||||
const propertyFromAction = action.props[property]
|
||||
const propertyType = propertyFromAction.type
|
||||
const skip = [PropertyType.BASIC_AUTH, PropertyType.OAUTH2, PropertyType.CUSTOM_AUTH, PropertyType.CUSTOM, PropertyType.MARKDOWN]
|
||||
if (skip.includes(propertyType) || property in operation.predefinedInput) {
|
||||
continue
|
||||
}
|
||||
const propertyPrompt = await buildPromptForProperty(property, propertyFromAction, operation, result)
|
||||
if (!isNil(propertyPrompt)) {
|
||||
propertyPrompts.push(propertyPrompt)
|
||||
}
|
||||
const propertySchema = await propertyToSchema(property, propertyFromAction, operation, result)
|
||||
propertyToFill[property] = propertyFromAction.required ? propertySchema : propertySchema.nullish()
|
||||
}
|
||||
const schemaObject = z.object(propertyToFill) as z.ZodTypeAny
|
||||
const extractionPrompt = constructExtractionPrompt(instruction, propertyToFill, propertyPrompts)
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: schemaObject,
|
||||
prompt: extractionPrompt,
|
||||
})
|
||||
result = {
|
||||
...result,
|
||||
...(object as Record<string, unknown>),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
async function execute(operation: ExecuteToolOperationWithModel): Promise<ExecuteToolResponse> {
|
||||
const { pieceAction } = await pieceLoader.getPieceAndActionOrThrow({
|
||||
pieceName: operation.pieceName,
|
||||
pieceVersion: operation.pieceVersion,
|
||||
actionName: operation.actionName,
|
||||
devPieces: EngineConstants.DEV_PIECES,
|
||||
})
|
||||
const depthToPropertyMap = tsort.sortPropertiesByDependencies(pieceAction.props)
|
||||
const resolvedInput = await resolveProperties(depthToPropertyMap, operation.instruction, pieceAction, operation.model, operation)
|
||||
const step: PieceAction = {
|
||||
name: operation.actionName,
|
||||
displayName: operation.actionName,
|
||||
type: FlowActionType.PIECE,
|
||||
settings: {
|
||||
input: resolvedInput,
|
||||
actionName: operation.actionName,
|
||||
pieceName: operation.pieceName,
|
||||
pieceVersion: operation.pieceVersion,
|
||||
propertySettings: Object.fromEntries(Object.entries(resolvedInput).map(([key]) => [key, {
|
||||
type: PropertyExecutionType.MANUAL,
|
||||
schema: undefined,
|
||||
}])),
|
||||
},
|
||||
valid: true,
|
||||
}
|
||||
|
||||
const output = await flowExecutor.getExecutorForAction(step.type).handle({
|
||||
action: step,
|
||||
executionState: FlowExecutorContext.empty(),
|
||||
constants: EngineConstants.fromExecuteActionInput(operation),
|
||||
})
|
||||
|
||||
const { output: stepOutput, errorMessage, status } = output.steps[operation.actionName]
|
||||
|
||||
return {
|
||||
status: status === StepOutputStatus.FAILED ? ExecutionToolStatus.FAILED : ExecutionToolStatus.SUCCESS,
|
||||
output: stepOutput,
|
||||
resolvedInput: {
|
||||
...resolvedInput,
|
||||
auth: 'Redacted',
|
||||
},
|
||||
errorMessage,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const constructExtractionPrompt = (instruction: string, propertyToFill: Record<string, z.ZodTypeAny>, propertyPrompts: string[]): string => {
|
||||
const propertyNames = Object.keys(propertyToFill).join('", "')
|
||||
return `
|
||||
You are an expert at understanding API schemas and filling out properties based on user instructions.
|
||||
|
||||
TASK: Fill out the properties "${propertyNames}" based on the user's instructions.
|
||||
|
||||
USER INSTRUCTIONS:
|
||||
${instruction}
|
||||
|
||||
${propertyPrompts.join('\n')}
|
||||
|
||||
IMPORTANT:
|
||||
- For dropdown, multi-select dropdown, and static dropdown properties, YOU MUST SELECT VALUES FROM THE PROVIDED OPTIONS ARRAY ONLY.
|
||||
- For array properties, YOU MUST SELECT VALUES FROM THE PROVIDED OPTIONS ARRAY ONLY.
|
||||
- For dynamic properties, YOU MUST SELECT VALUES FROM THE PROVIDED OPTIONS ARRAY ONLY.
|
||||
- THE OPTIONS ARRAY WILL BE [{ label: string, value: string | object }]. YOU MUST SELECT THE value FIELD FROM THE OPTION OBJECT.
|
||||
- For DATE_TIME properties, return date strings in ISO format (YYYY-MM-DDTHH:mm:ss.sssZ)
|
||||
- Use actual values from the user instructions to determine the correct value for each property, either as a hint for selecting options from dropdowns or to fill in the property if possible.
|
||||
- Must include all required properties, even if the user does not provide a value. If a required field is missing, look up the correct value or provide a reasonable default—otherwise, the task may fail.
|
||||
- IMPORTANT: If a property is not required and you do not have any information to fill it, you MUST skip it.
|
||||
`
|
||||
}
|
||||
|
||||
type ExecuteToolOperationWithModel = ExecuteToolOperation & {
|
||||
model: LanguageModel
|
||||
}
|
||||
|
||||
|
||||
async function propertyToSchema(propertyName: string, property: PieceProperty, operation: ExecuteToolOperation, resolvedInput: Record<string, unknown>): Promise<z.ZodTypeAny> {
|
||||
let schema: z.ZodTypeAny
|
||||
switch (property.type) {
|
||||
case PropertyType.SHORT_TEXT:
|
||||
case PropertyType.LONG_TEXT:
|
||||
case PropertyType.MARKDOWN:
|
||||
case PropertyType.DATE_TIME:
|
||||
case PropertyType.FILE:
|
||||
case PropertyType.COLOR:
|
||||
schema = z.string()
|
||||
break
|
||||
case PropertyType.DROPDOWN:
|
||||
case PropertyType.STATIC_DROPDOWN: {
|
||||
schema = z.union([z.string(), z.number(), z.record(z.string(), z.unknown())])
|
||||
break
|
||||
}
|
||||
case PropertyType.MULTI_SELECT_DROPDOWN:
|
||||
case PropertyType.STATIC_MULTI_SELECT_DROPDOWN: {
|
||||
schema = z.union([z.array(z.string()), z.array(z.record(z.string(), z.unknown()))])
|
||||
break
|
||||
}
|
||||
case PropertyType.NUMBER:
|
||||
schema = z.number()
|
||||
break
|
||||
case PropertyType.ARRAY:
|
||||
return z.array(z.unknown())
|
||||
case PropertyType.OBJECT:
|
||||
schema = z.record(z.string(), z.unknown())
|
||||
break
|
||||
case PropertyType.JSON:
|
||||
schema = z.record(z.string(), z.unknown())
|
||||
break
|
||||
case PropertyType.DYNAMIC: {
|
||||
schema = await buildDynamicSchema(propertyName, operation, resolvedInput)
|
||||
break
|
||||
}
|
||||
case PropertyType.CHECKBOX:
|
||||
schema = z.boolean()
|
||||
break
|
||||
case PropertyType.CUSTOM:
|
||||
schema = z.string()
|
||||
break
|
||||
case PropertyType.OAUTH2:
|
||||
case PropertyType.BASIC_AUTH:
|
||||
case PropertyType.CUSTOM_AUTH:
|
||||
case PropertyType.SECRET_TEXT:
|
||||
throw new Error(`Unsupported property type: ${property.type}`)
|
||||
}
|
||||
if (property.defaultValue) {
|
||||
schema = schema.default(property.defaultValue)
|
||||
}
|
||||
if (property.description) {
|
||||
schema = schema.describe(property.description)
|
||||
}
|
||||
return property.required ? schema : schema.nullish()
|
||||
}
|
||||
|
||||
async function buildDynamicSchema(propertyName: string, operation: ExecuteToolOperation, resolvedInput: Record<string, unknown>): Promise<z.ZodTypeAny> {
|
||||
const response = await pieceHelper.executeProps({
|
||||
...operation,
|
||||
propertyName,
|
||||
actionOrTriggerName: operation.actionName,
|
||||
input: resolvedInput,
|
||||
sampleData: {},
|
||||
searchValue: undefined,
|
||||
}) as unknown as ExecutePropsResult<PropertyType.DYNAMIC>
|
||||
const dynamicProperties = response.options
|
||||
const dynamicSchema: Record<string, z.ZodTypeAny> = {}
|
||||
for (const [key, value] of Object.entries(dynamicProperties)) {
|
||||
dynamicSchema[key] = await propertyToSchema(key, value, operation, resolvedInput)
|
||||
}
|
||||
return z.object(dynamicSchema)
|
||||
}
|
||||
|
||||
|
||||
async function buildPromptForProperty(propertyName: string, property: PieceProperty, operation: ExecuteToolOperation, input: Record<string, unknown>): Promise<string | null> {
|
||||
if (property.type === PropertyType.DROPDOWN || property.type === PropertyType.MULTI_SELECT_DROPDOWN) {
|
||||
const options = await loadOptions(propertyName, operation, input)
|
||||
return `The options for the property "${propertyName}" are: ${JSON.stringify(options)}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function loadOptions(propertyName: string, operation: ExecuteToolOperation, input: Record<string, unknown>): Promise<DropdownOption<unknown>[]> {
|
||||
const response = await pieceHelper.executeProps({
|
||||
...operation,
|
||||
propertyName,
|
||||
actionOrTriggerName: operation.actionName,
|
||||
input,
|
||||
sampleData: {},
|
||||
searchValue: undefined,
|
||||
}) as unknown as ExecutePropsResult<PropertyType.DROPDOWN | PropertyType.MULTI_SELECT_DROPDOWN>
|
||||
const options = response.options
|
||||
return options.options
|
||||
}
|
||||
|
||||
|
||||
type ConstructToolParams = {
|
||||
engineConstants: EngineConstants
|
||||
tools: AgentTool[]
|
||||
model: LanguageModel
|
||||
}
|
||||
56
activepieces-fork/packages/engine/src/lib/tools/tsort.ts
Normal file
56
activepieces-fork/packages/engine/src/lib/tools/tsort.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { PiecePropertyMap } from '@activepieces/pieces-framework'
|
||||
|
||||
export const tsort = {
|
||||
sortPropertiesByDependencies(properties: PiecePropertyMap): Record<number, string[]> {
|
||||
const inDegree: Record<string, number> = {}
|
||||
const graph: Record<string, string[]> = {}
|
||||
const depth: Record<string, number> = {}
|
||||
|
||||
Object.entries(properties).forEach(([key, property]) => {
|
||||
const hasRefreshers = 'refreshers' in property && property.refreshers && Array.isArray(property.refreshers) && property.refreshers.length > 0
|
||||
if (hasRefreshers) {
|
||||
for (const refresher of property.refreshers) {
|
||||
if (typeof properties[refresher] === 'undefined' || properties[refresher] === null) {
|
||||
continue
|
||||
}
|
||||
inDegree[key] = (inDegree[key] || 0) + 1
|
||||
graph[refresher] = graph[refresher] ?? []
|
||||
graph[refresher].push(key)
|
||||
}
|
||||
}
|
||||
inDegree[key] = inDegree[key] ?? 0
|
||||
graph[key] = graph[key] ?? []
|
||||
})
|
||||
|
||||
// Topological sort
|
||||
const order: string[] = []
|
||||
const queue = Object.entries(inDegree)
|
||||
.filter(([, degree]) => degree === 0)
|
||||
.map(([name]) => name)
|
||||
|
||||
queue.forEach(property => depth[property] = 0)
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!
|
||||
order.push(current)
|
||||
|
||||
const neighbors = graph[current] || []
|
||||
neighbors.forEach(neighbor => {
|
||||
inDegree[neighbor]--
|
||||
if (inDegree[neighbor] === 0) {
|
||||
queue.push(neighbor)
|
||||
depth[neighbor] = depth[current] + 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const depthToPropertyMap: Record<number, string[]> = {}
|
||||
for (const [property, depthValue] of Object.entries(depth)) {
|
||||
depthToPropertyMap[depthValue] = depthToPropertyMap[depthValue] ?? []
|
||||
depthToPropertyMap[depthValue].push(property)
|
||||
}
|
||||
|
||||
return depthToPropertyMap
|
||||
},
|
||||
}
|
||||
|
||||
106
activepieces-fork/packages/engine/src/lib/utils.ts
Executable file
106
activepieces-fork/packages/engine/src/lib/utils.ts
Executable file
@@ -0,0 +1,106 @@
|
||||
import fs from 'fs/promises'
|
||||
import { inspect } from 'node:util'
|
||||
import path from 'path'
|
||||
import { ConnectionsManager, ContextVersion, PauseHookParams, RespondHookParams, StopHookParams } from '@activepieces/pieces-framework'
|
||||
import { ExecutionError, ExecutionErrorType, Result, tryCatch } from '@activepieces/shared'
|
||||
import { createConnectionService } from './services/connections.service'
|
||||
|
||||
export type FileEntry = {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export const utils = {
|
||||
async tryCatchAndThrowOnEngineError<T>(fn: () => Promise<T>): Promise<Result<T, ExecutionError>> {
|
||||
const result = await tryCatch<T, ExecutionError>(fn)
|
||||
if (isEngineError(result.error)) {
|
||||
throw result.error
|
||||
}
|
||||
return result
|
||||
},
|
||||
async walk(dirPath: string): Promise<FileEntry[]> {
|
||||
const entries: FileEntry[] = []
|
||||
|
||||
async function walkRecursive(currentPath: string) {
|
||||
const items = await fs.readdir(currentPath, { withFileTypes: true })
|
||||
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(currentPath, item.name)
|
||||
const absolutePath = path.resolve(fullPath)
|
||||
|
||||
entries.push({
|
||||
name: item.name,
|
||||
path: absolutePath,
|
||||
})
|
||||
|
||||
if (item.isDirectory()) {
|
||||
await walkRecursive(fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walkRecursive(dirPath)
|
||||
return entries
|
||||
},
|
||||
formatExecutionError(value: ExecutionError): string {
|
||||
try {
|
||||
return JSON.stringify({
|
||||
...value,
|
||||
...JSON.parse(value.message),
|
||||
}, null, 2)
|
||||
}
|
||||
catch (e) {
|
||||
return inspect(value)
|
||||
}
|
||||
},
|
||||
formatError(value: Error): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value.message), null, 2)
|
||||
}
|
||||
catch (e) {
|
||||
return inspect(value)
|
||||
}
|
||||
},
|
||||
async folderExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
createConnectionManager(params: CreateConnectionManagerParams): ConnectionsManager {
|
||||
return {
|
||||
get: async (key: string) => {
|
||||
const connection = await createConnectionService({ projectId: params.projectId, engineToken: params.engineToken, apiUrl: params.apiUrl, contextVersion: params.contextVersion }).obtain(key)
|
||||
if (params.target === 'actions') {
|
||||
params.hookResponse.tags.push(`connection:${key}`)
|
||||
}
|
||||
return connection
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
function isEngineError(error: unknown): error is ExecutionError {
|
||||
return error instanceof ExecutionError && error.type === ExecutionErrorType.ENGINE
|
||||
}
|
||||
|
||||
export type HookResponse = {
|
||||
type: 'paused'
|
||||
tags: string[]
|
||||
response: PauseHookParams
|
||||
} | {
|
||||
type: 'stopped'
|
||||
tags: string[]
|
||||
response: StopHookParams
|
||||
} | {
|
||||
type: 'respond'
|
||||
tags: string[]
|
||||
response: RespondHookParams
|
||||
} | {
|
||||
type: 'none'
|
||||
tags: string[]
|
||||
}
|
||||
type CreateConnectionManagerParams = { projectId: string, engineToken: string, apiUrl: string, target: 'triggers' | 'properties', contextVersion: ContextVersion | undefined } | { projectId: string, engineToken: string, apiUrl: string, target: 'actions', hookResponse: HookResponse, contextVersion: ContextVersion | undefined }
|
||||
@@ -0,0 +1,27 @@
|
||||
import { isObject } from '@activepieces/shared'
|
||||
import { ProcessorFn } from './types'
|
||||
|
||||
function getLongestArrayLengthInObject(props: Record<string, unknown>): number {
|
||||
return Math.max(
|
||||
...Object.values(props).map(value =>
|
||||
Array.isArray(value) ? value.length : 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function constructResultForIndex(props: Record<string, unknown>, index: number): Record<string, unknown> {
|
||||
return Object.entries(props).reduce((result, [key, value]) => {
|
||||
result[key] = Array.isArray(value) ? value[index] : value
|
||||
return result
|
||||
}, {} as Record<string, unknown>)
|
||||
}
|
||||
|
||||
export const arrayZipperProcessor: ProcessorFn = (_property, value) => {
|
||||
if (Array.isArray(value) || !isObject(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
return Array.from({ length: getLongestArrayLengthInObject(value) },
|
||||
(_, index) => constructResultForIndex(value, index),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import dayjs from 'dayjs'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import { ProcessorFn } from './types'
|
||||
|
||||
export const dateTimeProcessor: ProcessorFn = (_property, value) => {
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
const dateTimeString = value
|
||||
try {
|
||||
if (!dateTimeString) throw Error('Undefined input')
|
||||
return dayjs.tz(dateTimeString, 'UTC').toISOString()
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ApFile } from '@activepieces/pieces-framework'
|
||||
import { isNil, isString } from '@activepieces/shared'
|
||||
import axios from 'axios'
|
||||
import isBase64 from 'is-base64'
|
||||
import mime from 'mime-types'
|
||||
import { ProcessorFn } from './types'
|
||||
|
||||
export const fileProcessor: ProcessorFn = async (_property, urlOrBase64) => {
|
||||
if (isNil(urlOrBase64) || !isString(urlOrBase64)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const file = handleBase64File(urlOrBase64)
|
||||
if (!isNil(file)) {
|
||||
return file
|
||||
}
|
||||
return await handleUrlFile(urlOrBase64)
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function handleBase64File(propertyValue: string): ApFile | null {
|
||||
if (!isBase64(propertyValue, { allowMime: true })) {
|
||||
return null
|
||||
}
|
||||
const matches = propertyValue.match(/^data:([A-Za-z-+/]+);base64,(.+)$/) // example match: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAABlBMVEUAAAD///+l2Z/dAAAAM0lEQVR4nGP4/5/h/1+G/58ZDrAz3D/McH8yw83NDDeNGe4Ug9C9zwz3gVLMDA/A6P9/AFGGFyjOXZtQAAAAAElFTkSuQmCC
|
||||
if (!matches || matches?.length !== 3) {
|
||||
return null
|
||||
}
|
||||
const base64 = matches[2]
|
||||
const extension = mime.extension(matches[1]) || 'bin'
|
||||
return new ApFile(
|
||||
`unknown.${extension}`,
|
||||
Buffer.from(base64, 'base64'),
|
||||
extension,
|
||||
)
|
||||
}
|
||||
|
||||
async function handleUrlFile(path: string): Promise<ApFile | null> {
|
||||
const fileResponse = await axios.get(path, {
|
||||
responseType: 'arraybuffer',
|
||||
})
|
||||
|
||||
|
||||
const filename = getFileName(path, fileResponse.headers['content-disposition'], fileResponse.headers['content-type']) ?? 'unknown'
|
||||
const extension = filename.split('.').length > 1 ? filename.split('.').pop() : undefined
|
||||
|
||||
return new ApFile(
|
||||
filename,
|
||||
Buffer.from(fileResponse.data, 'binary'),
|
||||
extension,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function getFileName(path: string, disposition: string | null, mimeType: string | undefined): string | null {
|
||||
const url = new URL(path)
|
||||
if (isNil(disposition)) {
|
||||
const fileNameFromUrl = url.pathname.includes('/') && url.pathname.split('/').pop()?.includes('.') ? url.pathname.split('/').pop() : null
|
||||
if (!isNil(fileNameFromUrl)) {
|
||||
return fileNameFromUrl
|
||||
}
|
||||
const resolvedExtension = mimeType ? mime.extension(mimeType) : null
|
||||
return `unknown.${resolvedExtension ?? 'bin'}`
|
||||
}
|
||||
const utf8FilenameRegex = /filename\*=UTF-8''([\w%\-.]+)(?:; ?|$)/i
|
||||
if (utf8FilenameRegex.test(disposition)) {
|
||||
const result = utf8FilenameRegex.exec(disposition)
|
||||
if (result && result.length > 1) {
|
||||
return decodeURIComponent(result[1])
|
||||
}
|
||||
}
|
||||
// prevent ReDos attacks by anchoring the ascii regex to string start and
|
||||
// slicing off everything before 'filename='
|
||||
const filenameStart = disposition.toLowerCase().indexOf('filename=')
|
||||
const asciiFilenameRegex = /^filename=(["']?)(.*?[^\\])\1(?:; ?|$)/i
|
||||
|
||||
if (filenameStart >= 0) {
|
||||
const partialDisposition = disposition.slice(filenameStart)
|
||||
const matches = asciiFilenameRegex.exec(partialDisposition)
|
||||
if (matches != null && matches[2]) {
|
||||
return matches[2]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { PropertyType } from '@activepieces/pieces-framework'
|
||||
import { dateTimeProcessor } from './date-time'
|
||||
import { fileProcessor } from './file'
|
||||
import { jsonProcessor } from './json'
|
||||
import { numberProcessor } from './number'
|
||||
import { objectProcessor } from './object'
|
||||
import { textProcessor } from './text'
|
||||
import { ProcessorFn } from './types'
|
||||
|
||||
export const processors: Partial<Record<PropertyType, ProcessorFn>> = {
|
||||
JSON: jsonProcessor,
|
||||
OBJECT: objectProcessor,
|
||||
NUMBER: numberProcessor,
|
||||
LONG_TEXT: textProcessor,
|
||||
SHORT_TEXT: textProcessor,
|
||||
SECRET_TEXT: textProcessor,
|
||||
DATE_TIME: dateTimeProcessor,
|
||||
FILE: fileProcessor,
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { isNil } from '@activepieces/shared'
|
||||
import { ProcessorFn } from './types'
|
||||
|
||||
export const jsonProcessor: ProcessorFn = (_property, value) => {
|
||||
if (isNil(value)) {
|
||||
return value
|
||||
}
|
||||
try {
|
||||
if (typeof value === 'object') {
|
||||
return value
|
||||
}
|
||||
return JSON.parse(value)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { isNil } from '@activepieces/shared'
|
||||
import { ProcessorFn } from './types'
|
||||
|
||||
export const numberProcessor: ProcessorFn = (_property, value) => {
|
||||
if (isNil(value)) {
|
||||
return value
|
||||
}
|
||||
if (value === '') {
|
||||
return undefined
|
||||
}
|
||||
return Number(value)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { isNil } from '@activepieces/shared'
|
||||
import { ProcessorFn } from './types'
|
||||
|
||||
export const objectProcessor: ProcessorFn = (_property, value) => {
|
||||
if (isNil(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
}
|
||||
catch (e) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { isNil } from '@activepieces/shared'
|
||||
import { ProcessorFn } from './types'
|
||||
|
||||
export const textProcessor: ProcessorFn = (property, value) => {
|
||||
if (isNil(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
const result = value.toString()
|
||||
if (result.length === 0 && !property.required) {
|
||||
return undefined
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { PieceProperty } from '@activepieces/pieces-framework'
|
||||
|
||||
export type ProcessorFn<INPUT = any, OUTPUT = any> = (
|
||||
property: PieceProperty,
|
||||
value: INPUT,
|
||||
) => OUTPUT
|
||||
@@ -0,0 +1,197 @@
|
||||
import { getAuthPropertyForValue, InputPropertyMap, PieceAuthProperty, PieceProperty, PiecePropertyMap, PropertyType, StaticPropsValue } from '@activepieces/pieces-framework'
|
||||
import { AppConnectionValue, AUTHENTICATION_PROPERTY_NAME, isNil, isObject, PropertySettings } from '@activepieces/shared'
|
||||
import { z } from 'zod'
|
||||
import { processors } from './processors'
|
||||
import { arrayZipperProcessor } from './processors/array-zipper'
|
||||
|
||||
type PropsValidationError = {
|
||||
[key: string]: string[] | PropsValidationError | PropsValidationError[]
|
||||
}
|
||||
|
||||
export const propsProcessor = {
|
||||
applyProcessorsAndValidators: async (
|
||||
resolvedInput: StaticPropsValue<PiecePropertyMap>,
|
||||
props: InputPropertyMap,
|
||||
auth: PieceAuthProperty | PieceAuthProperty[] | undefined,
|
||||
requireAuth: boolean,
|
||||
propertySettings: Record<string, PropertySettings>,
|
||||
): Promise<{ processedInput: StaticPropsValue<PiecePropertyMap>, errors: PropsValidationError }> => {
|
||||
let dynamaicPropertiesSchema: Record<string, InputPropertyMap> | undefined = undefined
|
||||
if (Object.keys(propertySettings).length > 0) {
|
||||
dynamaicPropertiesSchema = Object.fromEntries(Object.entries(propertySettings).map(([key, propertySetting]) => [key, propertySetting.schema]))
|
||||
}
|
||||
const processedInput = { ...resolvedInput }
|
||||
const errors: PropsValidationError = {}
|
||||
const authValue: AppConnectionValue | undefined = resolvedInput[AUTHENTICATION_PROPERTY_NAME]
|
||||
if (authValue && requireAuth) {
|
||||
const authPropsToProcess = getAuthPropsToProcess(authValue, auth)
|
||||
if (authPropsToProcess) {
|
||||
const { processedInput: authProcessedInput, errors: authErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
resolvedInput[AUTHENTICATION_PROPERTY_NAME],
|
||||
authPropsToProcess,
|
||||
undefined,
|
||||
false,
|
||||
{},
|
||||
)
|
||||
processedInput[AUTHENTICATION_PROPERTY_NAME] = authProcessedInput
|
||||
if (Object.keys(authErrors).length > 0) {
|
||||
errors[AUTHENTICATION_PROPERTY_NAME] = authErrors
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(resolvedInput)) {
|
||||
const property = props[key]
|
||||
if (isNil(property)) {
|
||||
continue
|
||||
}
|
||||
if (property.type === PropertyType.DYNAMIC && !isNil(dynamaicPropertiesSchema?.[key])) {
|
||||
const { processedInput: itemProcessedInput, errors: itemErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
value,
|
||||
dynamaicPropertiesSchema[key],
|
||||
undefined,
|
||||
false,
|
||||
{},
|
||||
)
|
||||
processedInput[key] = itemProcessedInput
|
||||
if (Object.keys(itemErrors).length > 0) {
|
||||
errors[key] = itemErrors
|
||||
}
|
||||
}
|
||||
if (property.type === PropertyType.ARRAY && property.properties) {
|
||||
const arrayOfObjects = arrayZipperProcessor(property, value) ?? []
|
||||
const processedArray = []
|
||||
const processedErrors = []
|
||||
for (const item of arrayOfObjects) {
|
||||
const { processedInput: itemProcessedInput, errors: itemErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
item,
|
||||
property.properties,
|
||||
undefined,
|
||||
false,
|
||||
{},
|
||||
)
|
||||
processedArray.push(itemProcessedInput)
|
||||
processedErrors.push(itemErrors)
|
||||
}
|
||||
processedInput[key] = processedArray
|
||||
const isThereErrors = processedErrors.some(error => Object.keys(error).length > 0)
|
||||
if (isThereErrors) {
|
||||
errors[key] = {
|
||||
properties: processedErrors,
|
||||
}
|
||||
}
|
||||
}
|
||||
const processor = processors[property.type]
|
||||
if (processor) {
|
||||
processedInput[key] = await processor(property, processedInput[key])
|
||||
}
|
||||
|
||||
const shouldValidate = key !== AUTHENTICATION_PROPERTY_NAME && property.type !== PropertyType.MARKDOWN
|
||||
if (!shouldValidate) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(processedInput)) {
|
||||
const property = props[key]
|
||||
if (isNil(property)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const validationErrors = validateProperty(property, value, resolvedInput[key])
|
||||
if (validationErrors.length > 0) {
|
||||
errors[key] = validationErrors
|
||||
}
|
||||
}
|
||||
|
||||
return { processedInput, errors }
|
||||
},
|
||||
}
|
||||
|
||||
const validateProperty = (property: PieceProperty, value: unknown, originalValue: unknown): string[] => {
|
||||
let schema
|
||||
switch (property.type) {
|
||||
case PropertyType.SHORT_TEXT:
|
||||
case PropertyType.LONG_TEXT:
|
||||
schema = z.string({
|
||||
error: `Expected string, received: ${originalValue}`,
|
||||
})
|
||||
break
|
||||
case PropertyType.NUMBER:
|
||||
schema = z.number({
|
||||
error: `Expected number, received: ${originalValue}`,
|
||||
})
|
||||
break
|
||||
case PropertyType.CHECKBOX:
|
||||
schema = z.boolean({
|
||||
error: `Expected boolean, received: ${originalValue}`,
|
||||
})
|
||||
break
|
||||
case PropertyType.DATE_TIME:
|
||||
schema = z.string({
|
||||
error: `Invalid datetime format. Expected ISO format (e.g. 2024-03-14T12:00:00.000Z), received: ${originalValue}`,
|
||||
})
|
||||
break
|
||||
case PropertyType.ARRAY:
|
||||
schema = z.array(z.any(), {
|
||||
error: `Expected array, received: ${originalValue}`,
|
||||
})
|
||||
break
|
||||
case PropertyType.OBJECT:
|
||||
schema = z.record(z.any(), z.any(), {
|
||||
error: `Expected object, received: ${originalValue}`,
|
||||
})
|
||||
break
|
||||
case PropertyType.JSON:
|
||||
schema = z.any().refine(
|
||||
(val) => isObject(val) || Array.isArray(val),
|
||||
{
|
||||
message: `Expected JSON, received: ${originalValue}`,
|
||||
},
|
||||
)
|
||||
break
|
||||
case PropertyType.FILE: {
|
||||
schema = z.any().refine(
|
||||
(val) => isObject(val),
|
||||
{
|
||||
message: `Expected file url or base64 with mimeType, received: ${originalValue}`,
|
||||
},
|
||||
)
|
||||
break
|
||||
}
|
||||
default:
|
||||
schema = z.any()
|
||||
}
|
||||
let finalSchema
|
||||
if (property.required) {
|
||||
finalSchema = schema
|
||||
}
|
||||
else {
|
||||
finalSchema = schema.nullable().optional()
|
||||
}
|
||||
|
||||
try {
|
||||
finalSchema.parse(value)
|
||||
return []
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
return err.issues.map(e => e.message)
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthPropsToProcess(authValue: AppConnectionValue, auth: PieceAuthProperty | PieceAuthProperty[] | undefined): | null {
|
||||
if (isNil(auth)) {
|
||||
return null
|
||||
}
|
||||
const usedAuthProperty = getAuthPropertyForValue({
|
||||
authValueType: authValue.type,
|
||||
pieceAuth: auth,
|
||||
})
|
||||
const doesAuthHaveProps = usedAuthProperty?.type === PropertyType.CUSTOM_AUTH || usedAuthProperty?.type === PropertyType.OAUTH2
|
||||
if (doesAuthHaveProps && !isNil(usedAuthProperty?.props)) {
|
||||
return usedAuthProperty.props
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { ContextVersion } from '@activepieces/pieces-framework'
|
||||
import { applyFunctionToValues, isNil, isString } from '@activepieces/shared'
|
||||
import replaceAsync from 'string-replace-async'
|
||||
import { initCodeSandbox } from '../core/code/code-sandbox'
|
||||
import { FlowExecutorContext } from '../handler/context/flow-execution-context'
|
||||
import { createConnectionService } from '../services/connections.service'
|
||||
import { utils } from '../utils'
|
||||
|
||||
const VARIABLE_PATTERN = /\{\{(.*?)\}\}/g
|
||||
const CONNECTIONS = 'connections'
|
||||
const FLATTEN_NESTED_KEYS_PATTERN = /\{\{\s*flattenNestedKeys(.*?)\}\}/g
|
||||
|
||||
|
||||
export const createPropsResolver = ({ engineToken, projectId, apiUrl, contextVersion }: PropsResolverParams) => {
|
||||
return {
|
||||
resolve: async <T = unknown>(params: ResolveInputParams): Promise<ResolveResult<T>> => {
|
||||
const { unresolvedInput, executionState } = params
|
||||
if (isNil(unresolvedInput)) {
|
||||
return {
|
||||
resolvedInput: unresolvedInput as T,
|
||||
censoredInput: unresolvedInput,
|
||||
}
|
||||
}
|
||||
const currentState = executionState.currentState()
|
||||
const resolveOptions = {
|
||||
engineToken,
|
||||
projectId,
|
||||
apiUrl,
|
||||
currentState,
|
||||
}
|
||||
const resolvedInput = await applyFunctionToValues<T>(
|
||||
unresolvedInput,
|
||||
(token) => resolveInputAsync({
|
||||
...resolveOptions,
|
||||
input: token,
|
||||
censoredInput: false,
|
||||
contextVersion,
|
||||
}))
|
||||
const censoredInput = await applyFunctionToValues<T>(
|
||||
unresolvedInput,
|
||||
(token) => resolveInputAsync({
|
||||
...resolveOptions,
|
||||
input: token,
|
||||
censoredInput: true,
|
||||
contextVersion,
|
||||
}))
|
||||
return {
|
||||
resolvedInput,
|
||||
censoredInput,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const mergeFlattenedKeysArraysIntoOneArray = async (token: string, partsThatNeedResolving: string[],
|
||||
resolveOptions: Pick<ResolveInputInternalParams, 'engineToken' | 'projectId' | 'apiUrl' | 'currentState' | 'censoredInput'>,
|
||||
contextVersion: ContextVersion | undefined,
|
||||
) => {
|
||||
const resolvedValues: Record<string, unknown> = {}
|
||||
let longestResultLength = 0
|
||||
for (const tokenPart of partsThatNeedResolving) {
|
||||
const variableName = tokenPart.substring(2, tokenPart.length - 2)
|
||||
resolvedValues[tokenPart] = await resolveSingleToken({
|
||||
...resolveOptions,
|
||||
variableName,
|
||||
contextVersion,
|
||||
})
|
||||
if (Array.isArray(resolvedValues[tokenPart])) {
|
||||
longestResultLength = Math.max(longestResultLength, resolvedValues[tokenPart].length)
|
||||
}
|
||||
}
|
||||
const result = new Array(longestResultLength).fill(null).map((_, index) => {
|
||||
return Object.entries(resolvedValues).reduce((acc, [tokenPart, value]) => {
|
||||
const valueToUse = (Array.isArray(value) ? value[index] : value) ?? ''
|
||||
acc = acc.replace(tokenPart, isString(valueToUse) ? valueToUse : JSON.stringify(valueToUse))
|
||||
return acc
|
||||
}, token)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
export type PropsResolver = ReturnType<typeof createPropsResolver>
|
||||
/**
|
||||
* input: `Hello {{firstName}} {{lastName}}`
|
||||
* tokenThatNeedResolving: [`{{firstName}}`, `{{lastName}}`]
|
||||
*/
|
||||
async function resolveInputAsync(params: ResolveInputInternalParams): Promise<unknown> {
|
||||
const { input, currentState, engineToken, projectId, apiUrl, censoredInput } = params
|
||||
const tokensThatNeedResolving = input.match(VARIABLE_PATTERN)
|
||||
const inputContainsOnlyOneTokenToResolve = tokensThatNeedResolving !== null && tokensThatNeedResolving.length === 1 && tokensThatNeedResolving[0] === input
|
||||
const resolveOptions = {
|
||||
engineToken,
|
||||
projectId,
|
||||
apiUrl,
|
||||
currentState,
|
||||
censoredInput,
|
||||
}
|
||||
|
||||
if (inputContainsOnlyOneTokenToResolve) {
|
||||
const trimmedInput = input.trim()
|
||||
const variableName = trimmedInput.substring(2, trimmedInput.length - 2)
|
||||
return resolveSingleToken({
|
||||
...resolveOptions,
|
||||
variableName,
|
||||
contextVersion: params.contextVersion,
|
||||
})
|
||||
}
|
||||
const inputIncludesFlattenNestedKeysTokens = input.match(FLATTEN_NESTED_KEYS_PATTERN)
|
||||
if (!isNil(inputIncludesFlattenNestedKeysTokens) && !isNil(tokensThatNeedResolving)) {
|
||||
return mergeFlattenedKeysArraysIntoOneArray(input, tokensThatNeedResolving, resolveOptions, params.contextVersion)
|
||||
}
|
||||
|
||||
return replaceAsync(input, VARIABLE_PATTERN, async (_fullMatch, variableName) => {
|
||||
const result = await resolveSingleToken({
|
||||
...resolveOptions,
|
||||
variableName,
|
||||
contextVersion: params.contextVersion,
|
||||
})
|
||||
return isString(result) ? result : JSON.stringify(result)
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveSingleToken(params: ResolveSingleTokenParams): Promise<unknown> {
|
||||
const { variableName, currentState } = params
|
||||
const isConnection = variableName.startsWith(CONNECTIONS)
|
||||
if (isConnection) {
|
||||
return handleConnection(params)
|
||||
}
|
||||
return evalInScope(variableName, { ...currentState }, { flattenNestedKeys })
|
||||
}
|
||||
|
||||
async function handleConnection(params: ResolveSingleTokenParams): Promise<unknown> {
|
||||
const { variableName, engineToken, projectId, apiUrl, censoredInput } = params
|
||||
const connectionName = parseConnectionNameOnly(variableName)
|
||||
if (isNil(connectionName)) {
|
||||
return ''
|
||||
}
|
||||
if (censoredInput) {
|
||||
return '**REDACTED**'
|
||||
}
|
||||
const connection = await createConnectionService({ engineToken, projectId, apiUrl, contextVersion: params.contextVersion }).obtain(connectionName)
|
||||
const pathAfterConnectionName = parsePathAfterConnectionName(variableName, connectionName)
|
||||
if (isNil(pathAfterConnectionName) || pathAfterConnectionName.length === 0) {
|
||||
return connection
|
||||
}
|
||||
return evalInScope(pathAfterConnectionName, { connection }, { flattenNestedKeys })
|
||||
}
|
||||
|
||||
function parsePathAfterConnectionName(variableName: string, connectionName: string): string | null {
|
||||
if (variableName.includes('[')) {
|
||||
return variableName.substring(`connections.['${connectionName}']`.length)
|
||||
}
|
||||
const cp = variableName.substring(`connections.${connectionName}`.length)
|
||||
if (cp.length === 0) {
|
||||
return cp
|
||||
}
|
||||
return `connection${cp}`
|
||||
}
|
||||
|
||||
function parseConnectionNameOnly(variableName: string): string | null {
|
||||
const connectionWithNewFormatSquareBrackets = variableName.includes('[')
|
||||
if (connectionWithNewFormatSquareBrackets) {
|
||||
return parseSquareBracketConnectionPath(variableName)
|
||||
}
|
||||
// {{connections.connectionName.path}}
|
||||
// This does not work If connectionName contains .
|
||||
return variableName.split('.')?.[1]
|
||||
}
|
||||
|
||||
function parseSquareBracketConnectionPath(variableName: string): string | null {
|
||||
// Find the connection name inside {{connections['connectionName'].path}}
|
||||
const matches = variableName.match(/\['([^']+)'\]/g)
|
||||
if (matches && matches.length >= 1) {
|
||||
// Remove the square brackets and quotes from the connection name
|
||||
|
||||
const secondPath = matches[0].replace(/\['|'\]/g, '')
|
||||
return secondPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
async function evalInScope(js: string, contextAsScope: Record<string, unknown>, functions: Record<string, Function>): Promise<unknown> {
|
||||
const { data: result, error: resultError } = await utils.tryCatchAndThrowOnEngineError((async () => {
|
||||
const codeSandbox = await initCodeSandbox()
|
||||
|
||||
const result = await codeSandbox.runScript({
|
||||
script: js,
|
||||
scriptContext: contextAsScope,
|
||||
functions,
|
||||
})
|
||||
return result ?? ''
|
||||
}))
|
||||
|
||||
if (resultError) {
|
||||
console.warn('[evalInScope] Error evaluating variable', resultError)
|
||||
return ''
|
||||
}
|
||||
return result ?? ''
|
||||
}
|
||||
|
||||
function flattenNestedKeys(data: unknown, pathToMatch: string[]): unknown[] {
|
||||
if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
|
||||
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
|
||||
if (key === pathToMatch[0]) {
|
||||
return flattenNestedKeys(value, pathToMatch.slice(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (Array.isArray(data)) {
|
||||
return data.flatMap((d) => flattenNestedKeys(d, pathToMatch))
|
||||
}
|
||||
else if (pathToMatch.length === 0) {
|
||||
return [data]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
type ResolveSingleTokenParams = {
|
||||
variableName: string
|
||||
currentState: Record<string, unknown>
|
||||
engineToken: string
|
||||
projectId: string
|
||||
apiUrl: string
|
||||
censoredInput: boolean
|
||||
contextVersion: ContextVersion | undefined
|
||||
}
|
||||
|
||||
type ResolveInputInternalParams = {
|
||||
input: string
|
||||
engineToken: string
|
||||
projectId: string
|
||||
apiUrl: string
|
||||
censoredInput: boolean
|
||||
currentState: Record<string, unknown>
|
||||
contextVersion: ContextVersion | undefined
|
||||
}
|
||||
|
||||
type ResolveInputParams = {
|
||||
unresolvedInput: unknown
|
||||
executionState: FlowExecutorContext
|
||||
}
|
||||
|
||||
type ResolveResult<T = unknown> = {
|
||||
resolvedInput: T
|
||||
censoredInput: unknown
|
||||
}
|
||||
|
||||
type PropsResolverParams = {
|
||||
engineToken: string
|
||||
projectId: string
|
||||
apiUrl: string
|
||||
contextVersion: ContextVersion | undefined
|
||||
}
|
||||
110
activepieces-fork/packages/engine/src/lib/worker-socket.ts
Normal file
110
activepieces-fork/packages/engine/src/lib/worker-socket.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { inspect } from 'util'
|
||||
import {
|
||||
emitWithAck,
|
||||
EngineGenericError,
|
||||
EngineOperation,
|
||||
EngineOperationType,
|
||||
EngineResponse,
|
||||
EngineResponseStatus,
|
||||
EngineSocketEvent,
|
||||
EngineStderr,
|
||||
EngineStdout,
|
||||
ERROR_MESSAGES_TO_REDACT,
|
||||
isNil,
|
||||
} from '@activepieces/shared'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import { execute } from './operations'
|
||||
import { utils } from './utils'
|
||||
|
||||
const WORKER_ID = process.env.WORKER_ID
|
||||
const WS_URL = 'ws://127.0.0.1:12345'
|
||||
|
||||
let socket: Socket | undefined
|
||||
|
||||
async function executeFromSocket(operation: EngineOperation, operationType: EngineOperationType): Promise<void> {
|
||||
const result = await execute(operationType, operation)
|
||||
const resultParsed = JSON.parse(JSON.stringify(result))
|
||||
await workerSocket.sendToWorkerWithAck(EngineSocketEvent.ENGINE_RESPONSE, resultParsed)
|
||||
}
|
||||
|
||||
export const workerSocket = {
|
||||
init: (): void => {
|
||||
if (isNil(WORKER_ID)) {
|
||||
throw new EngineGenericError('WorkerIdNotSetError', 'WORKER_ID environment variable is not set')
|
||||
}
|
||||
|
||||
socket = io(WS_URL, {
|
||||
path: '/worker/ws',
|
||||
auth: {
|
||||
workerId: WORKER_ID,
|
||||
},
|
||||
autoConnect: true,
|
||||
reconnection: true,
|
||||
})
|
||||
|
||||
// Redirect console.log/error to socket
|
||||
const originalLog = console.log
|
||||
console.log = function (...args): void {
|
||||
const engineStdout: EngineStdout = {
|
||||
message: args.join(' ') + '\n',
|
||||
}
|
||||
socket?.emit(EngineSocketEvent.ENGINE_STDOUT, engineStdout)
|
||||
originalLog.apply(console, args)
|
||||
}
|
||||
|
||||
const originalError = console.error
|
||||
console.error = function (...args): void {
|
||||
let sanitizedArgs = [...args]
|
||||
if (typeof args[0] === 'string' && ERROR_MESSAGES_TO_REDACT.some(errorMessage => args[0].includes(errorMessage))) {
|
||||
sanitizedArgs = [sanitizedArgs[0], 'REDACTED']
|
||||
}
|
||||
const engineStderr: EngineStderr = {
|
||||
message: sanitizedArgs.join(' ') + '\n',
|
||||
}
|
||||
socket?.emit(EngineSocketEvent.ENGINE_STDERR, engineStderr)
|
||||
|
||||
originalError.apply(console, sanitizedArgs)
|
||||
}
|
||||
|
||||
socket.on(EngineSocketEvent.ENGINE_OPERATION, async (data: { operation: EngineOperation, operationType: EngineOperationType }) => {
|
||||
const { error: resultError } = await utils.tryCatchAndThrowOnEngineError(() =>
|
||||
executeFromSocket(data.operation, data.operationType),
|
||||
)
|
||||
|
||||
if (resultError) {
|
||||
const engineError: EngineResponse = {
|
||||
response: undefined,
|
||||
status: EngineResponseStatus.INTERNAL_ERROR,
|
||||
error: utils.formatExecutionError(resultError),
|
||||
}
|
||||
console.error(utils.formatExecutionError(resultError))
|
||||
await workerSocket.sendToWorkerWithAck(EngineSocketEvent.ENGINE_RESPONSE, engineError)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
},
|
||||
|
||||
sendToWorkerWithAck: async (
|
||||
type: EngineSocketEvent,
|
||||
data: unknown,
|
||||
): Promise<void> => {
|
||||
await emitWithAck(socket, type, data, {
|
||||
timeoutMs: 4000,
|
||||
retries: 4,
|
||||
retryDelayMs: 1000,
|
||||
})
|
||||
},
|
||||
|
||||
sendError: async (error: unknown): Promise<void> => {
|
||||
const engineStderr: EngineStderr = {
|
||||
message: inspect(error),
|
||||
}
|
||||
await emitWithAck(socket, EngineSocketEvent.ENGINE_STDERR, engineStderr, {
|
||||
timeoutMs: 3000,
|
||||
retries: 4,
|
||||
retryDelayMs: 1000,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
21
activepieces-fork/packages/engine/src/main.ts
Executable file
21
activepieces-fork/packages/engine/src/main.ts
Executable file
@@ -0,0 +1,21 @@
|
||||
import { isNil } from '@activepieces/shared'
|
||||
import { workerSocket } from './lib/worker-socket'
|
||||
|
||||
const WORKER_ID = process.env.WORKER_ID
|
||||
process.title = `engine-${WORKER_ID}`
|
||||
|
||||
if (!isNil(WORKER_ID)) {
|
||||
workerSocket.init()
|
||||
}
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
void workerSocket.sendError(error).catch().finally(() => {
|
||||
process.exit(3)
|
||||
})
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
void workerSocket.sendError(reason).catch().finally(() => {
|
||||
process.exit(4)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,312 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { BranchCondition, BranchOperator } from '@activepieces/shared'
|
||||
import { evaluateConditions } from '../../src/lib/handler/router-executor'
|
||||
|
||||
describe('Branch evaluateConditions', () => {
|
||||
describe('DATE_IS_AFTER', () => {
|
||||
test.each([
|
||||
null,
|
||||
undefined,
|
||||
'not a date',
|
||||
])('should return false when one of the values is not a date %p', (value) => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: value as string,
|
||||
secondValue: '2021-01-01',
|
||||
operator: BranchOperator.DATE_IS_AFTER,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return true when first date is after second date', () => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: '2021-01-02',
|
||||
secondValue: '2021-01-01',
|
||||
operator: BranchOperator.DATE_IS_AFTER,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(true)
|
||||
})
|
||||
|
||||
test.each([
|
||||
'2021-01-01',
|
||||
'2021-01-02',
|
||||
])('should return false when first date is before or equal to second date', (firstDate) => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: firstDate,
|
||||
secondValue: '2021-01-02',
|
||||
operator: BranchOperator.DATE_IS_AFTER,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false when the date is not in a supported format', () => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: '2021-01-02T00:00:00Z',
|
||||
secondValue: '1st January 2021',
|
||||
operator: BranchOperator.DATE_IS_AFTER,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(false)
|
||||
})
|
||||
|
||||
test('should compare time', () => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: '2021-01-01T00:00:02Z',
|
||||
secondValue: '2021-01-01T00:00:01Z',
|
||||
operator: BranchOperator.DATE_IS_AFTER,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DATE_IS_BEFORE', () => {
|
||||
test.each([
|
||||
null,
|
||||
undefined,
|
||||
'not a date',
|
||||
])('should return false when one of the values is not a date %p', (value) => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: value as string,
|
||||
secondValue: '2021-01-01',
|
||||
operator: BranchOperator.DATE_IS_BEFORE,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return true when first date is before second date', () => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: '2021-01-01',
|
||||
secondValue: '2021-01-02',
|
||||
operator: BranchOperator.DATE_IS_BEFORE,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(true)
|
||||
})
|
||||
|
||||
test.each([
|
||||
'2021-01-01',
|
||||
'2021-01-02',
|
||||
])('should return false when first date is after or equal to second date', (firstDate) => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: firstDate,
|
||||
secondValue: '2021-01-01',
|
||||
operator: BranchOperator.DATE_IS_BEFORE,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false when the date is not in a supported format', () => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: '2021-01-02T00:00:00Z',
|
||||
secondValue: '2nd January 2021',
|
||||
operator: BranchOperator.DATE_IS_BEFORE,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(false)
|
||||
})
|
||||
|
||||
test('should compare time', () => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: '2021-01-01T00:00:01Z',
|
||||
secondValue: '2021-01-01T00:00:02Z',
|
||||
operator: BranchOperator.DATE_IS_BEFORE,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DATE_IS_EQUAL', () => {
|
||||
test.each([
|
||||
null,
|
||||
undefined,
|
||||
'not a date',
|
||||
])('should return false when one of the values is not a date %p', (value) => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: value as string,
|
||||
secondValue: '2021-01-01',
|
||||
operator: BranchOperator.DATE_IS_EQUAL,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return true when first date is equal to second date', () => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: '2021-01-01',
|
||||
secondValue: '2021-01-01',
|
||||
operator: BranchOperator.DATE_IS_EQUAL,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(true)
|
||||
})
|
||||
|
||||
test.each([
|
||||
'2021-01-01',
|
||||
'2021-01-03',
|
||||
])('should return false when first date is after or before the second date', (firstDate) => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: firstDate,
|
||||
secondValue: '2021-01-02',
|
||||
operator: BranchOperator.DATE_IS_EQUAL,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false when the date is not in a supported format', () => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: '2021-01-02T00:00:00Z',
|
||||
secondValue: '2nd January 2021',
|
||||
operator: BranchOperator.DATE_IS_EQUAL,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(false)
|
||||
})
|
||||
|
||||
test('should compare time', () => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: '2021-01-01T00:00:01Z',
|
||||
secondValue: '2021-01-01T00:00:01Z',
|
||||
operator: BranchOperator.DATE_IS_EQUAL,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LIST_IS_EMPTY', () => {
|
||||
test.each([
|
||||
[],
|
||||
'[]',
|
||||
])('should return true when list is empty %p', (input: any) => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: input,
|
||||
operator: BranchOperator.LIST_IS_EMPTY,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(true)
|
||||
})
|
||||
|
||||
test.each([
|
||||
[1],
|
||||
'[1]',
|
||||
])('should return false when list is not empty %p', (input: any) => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: input,
|
||||
operator: BranchOperator.LIST_IS_EMPTY,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(false)
|
||||
})
|
||||
|
||||
test.each([
|
||||
null,
|
||||
undefined,
|
||||
'not a list',
|
||||
{},
|
||||
])('should return false when the value is not a list %p', (input: any) => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: input,
|
||||
operator: BranchOperator.LIST_IS_EMPTY,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LIST_IS_NOT_EMPTY', () => {
|
||||
test.each([
|
||||
[1],
|
||||
'[1]',
|
||||
])('should return true when list is not empty %p', (input: any) => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: input,
|
||||
operator: BranchOperator.LIST_IS_NOT_EMPTY,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(true)
|
||||
})
|
||||
|
||||
test.each([
|
||||
[],
|
||||
'[]',
|
||||
])('should return false when list is empty %p', (input: any) => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: input,
|
||||
operator: BranchOperator.LIST_IS_NOT_EMPTY,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(false)
|
||||
})
|
||||
|
||||
test.each([
|
||||
null,
|
||||
undefined,
|
||||
'not a list',
|
||||
{},
|
||||
])('should return false when the value is not a list %p', (input: any) => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: input,
|
||||
operator: BranchOperator.LIST_IS_NOT_EMPTY,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LIST_CONTAINS', () => {
|
||||
test.each([
|
||||
{ expected: true, list: ['apple', 'banana', 'cherry'], value: 'banana', caseSensitive: false },
|
||||
{ expected: false, list: ['apple', 'banana', 'cherry'], value: 'Banana', caseSensitive: true },
|
||||
{ expected: true, list: ['apple', 'banana', 'cherry'], value: 'Banana', caseSensitive: false },
|
||||
{ expected: true, list: '["apple", "banana", "cherry"]', value: 'banana', caseSensitive: false },
|
||||
{ expected: true, list: 'apple', value: 'apple', caseSensitive: false },
|
||||
{ expected: true, list: [1, 2, 3, 4, 5], value: '4', caseSensitive: false },
|
||||
{ expected: true, list: [1, 2, 3, 4, 5], value: 4, caseSensitive: false },
|
||||
{ expected: true, list: [true, false, true], value: 'true', caseSensitive: false },
|
||||
{ expected: true, list: [true, false, true], value: true, caseSensitive: false },
|
||||
{ expected: true, list: ['true', 'false', 'true'], value: true, caseSensitive: false },
|
||||
{ expected: true, list: ['true', 'false', 'true'], value: 'true', caseSensitive: false },
|
||||
])('should return $expected for list $list containing $value (case sensitive: $caseSensitive)', ({ expected, list, value, caseSensitive }) => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: list as any,
|
||||
secondValue: value as any,
|
||||
operator: BranchOperator.LIST_CONTAINS,
|
||||
caseSensitive,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LIST_DOES_NOT_CONTAIN', () => {
|
||||
test.each([
|
||||
{ expected: true, list: ['apple', 'banana', 'cherry'], value: 'grape', caseSensitive: false },
|
||||
{ expected: true, list: ['apple', 'banana', 'cherry'], value: 'Banana', caseSensitive: true },
|
||||
{ expected: false, list: ['apple', 'banana', 'cherry'], value: 'Banana', caseSensitive: false },
|
||||
{ expected: true, list: '["apple", "banana", "cherry"]', value: 'grape', caseSensitive: false },
|
||||
{ expected: true, list: 'apple', value: 'grape', caseSensitive: false },
|
||||
{ expected: true, list: [1, 2, 3, 4, 5], value: '6', caseSensitive: false },
|
||||
{ expected: true, list: [1, 2, 3, 4, 5], value: 6, caseSensitive: false },
|
||||
{ expected: false, list: [true, false, true], value: 'false', caseSensitive: false },
|
||||
{ expected: false, list: [true, false, true], value: false, caseSensitive: false },
|
||||
{ expected: false, list: ['true', 'false', 'true'], value: false, caseSensitive: false },
|
||||
{ expected: false, list: ['true', 'false', 'true'], value: 'false', caseSensitive: false },
|
||||
])('should return $expected for list $list not containing $value (case sensitive: $caseSensitive)', ({ expected, list, value, caseSensitive }) => {
|
||||
const condition: BranchCondition = {
|
||||
firstValue: list as any,
|
||||
secondValue: value as any,
|
||||
operator: BranchOperator.LIST_DOES_NOT_CONTAIN,
|
||||
caseSensitive,
|
||||
}
|
||||
|
||||
expect(evaluateConditions([[condition]])).toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,521 @@
|
||||
import { BranchCondition, BranchOperator, FlowRunStatus, RouterExecutionType } from '@activepieces/shared'
|
||||
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
|
||||
import { flowExecutor } from '../../src/lib/handler/flow-executor'
|
||||
import { buildCodeAction, buildRouterWithOneCondition, generateMockEngineConstants } from './test-helper'
|
||||
|
||||
function executeBranchActionWithOneCondition(condition: BranchCondition): Promise<FlowExecutorContext> {
|
||||
return flowExecutor.execute({
|
||||
action: buildRouterWithOneCondition({
|
||||
conditions: [condition],
|
||||
executionType: RouterExecutionType.EXECUTE_FIRST_MATCH,
|
||||
children: [
|
||||
buildCodeAction({
|
||||
name: 'echo_step',
|
||||
input: {
|
||||
condition: true,
|
||||
},
|
||||
}),
|
||||
buildCodeAction({
|
||||
name: 'echo_step_1',
|
||||
input: {
|
||||
condition: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
executionState: FlowExecutorContext.empty(),
|
||||
constants: generateMockEngineConstants(),
|
||||
})
|
||||
}
|
||||
|
||||
describe('flow with branching different branches', () => {
|
||||
|
||||
it('should execute branch with text contains condition (case insensitive)', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.TEXT_CONTAINS,
|
||||
firstValue: 'test',
|
||||
secondValue: 'TeSt',
|
||||
caseSensitive: false,
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with text does not contain condition (case insensitive)', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.TEXT_DOES_NOT_CONTAIN,
|
||||
firstValue: 'test',
|
||||
secondValue: 'ExAmPlE',
|
||||
caseSensitive: false,
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with text exactly matches condition (case insensitive)', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
|
||||
firstValue: 'test',
|
||||
secondValue: 'TeSt',
|
||||
caseSensitive: false,
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with text does not exactly match condition (case insensitive)', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.TEXT_DOES_NOT_EXACTLY_MATCH,
|
||||
firstValue: 'test',
|
||||
secondValue: 'ExAmPlE',
|
||||
caseSensitive: false,
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with text starts with condition (case insensitive)', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.TEXT_STARTS_WITH,
|
||||
firstValue: 'test',
|
||||
secondValue: 'tE',
|
||||
caseSensitive: false,
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with text does not start with condition (case insensitive)', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.TEXT_DOES_NOT_START_WITH,
|
||||
firstValue: 'test',
|
||||
secondValue: 'eS',
|
||||
caseSensitive: false,
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with text ends with condition (case insensitive)', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.TEXT_ENDS_WITH,
|
||||
firstValue: 'test',
|
||||
secondValue: 'sT',
|
||||
caseSensitive: false,
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with text does not end with condition (case insensitive)', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.TEXT_DOES_NOT_END_WITH,
|
||||
firstValue: 'test',
|
||||
secondValue: 'eS',
|
||||
caseSensitive: false,
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with text contains condition', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.TEXT_CONTAINS,
|
||||
firstValue: 'test',
|
||||
secondValue: 'test',
|
||||
caseSensitive: true,
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with text does not contain condition', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.TEXT_DOES_NOT_CONTAIN,
|
||||
firstValue: 'test',
|
||||
secondValue: 'example',
|
||||
caseSensitive: true,
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with text exactly matches condition', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
|
||||
firstValue: 'test',
|
||||
secondValue: 'test',
|
||||
caseSensitive: true,
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with text does not exactly match condition', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.TEXT_DOES_NOT_EXACTLY_MATCH,
|
||||
firstValue: 'test',
|
||||
secondValue: 'example',
|
||||
caseSensitive: true,
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with text starts with condition', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.TEXT_STARTS_WITH,
|
||||
firstValue: 'test',
|
||||
secondValue: 'te',
|
||||
caseSensitive: true,
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with text does not start with condition', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.TEXT_DOES_NOT_START_WITH,
|
||||
firstValue: 'test',
|
||||
secondValue: 'es',
|
||||
caseSensitive: true,
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with text ends with condition', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.TEXT_ENDS_WITH,
|
||||
firstValue: 'test',
|
||||
secondValue: 'st',
|
||||
caseSensitive: true,
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with text does not end with condition', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.TEXT_DOES_NOT_END_WITH,
|
||||
firstValue: 'test',
|
||||
secondValue: 'es',
|
||||
caseSensitive: true,
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with exists condition', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.EXISTS,
|
||||
firstValue: 'test',
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with does not exist condition', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.DOES_NOT_EXIST,
|
||||
firstValue: '',
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with boolean is true condition', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.BOOLEAN_IS_TRUE,
|
||||
firstValue: 'true',
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with boolean is false condition', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.BOOLEAN_IS_FALSE,
|
||||
firstValue: '{{false}}',
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with two equal numbers', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.NUMBER_IS_EQUAL_TO,
|
||||
firstValue: '1',
|
||||
secondValue: '1',
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with the first number greater than the second one', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.NUMBER_IS_GREATER_THAN,
|
||||
firstValue: '2',
|
||||
secondValue: '1',
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should execute branch with the first number less than the second one', async () => {
|
||||
const result = await executeBranchActionWithOneCondition(
|
||||
{
|
||||
operator: BranchOperator.NUMBER_IS_LESS_THAN,
|
||||
firstValue: '1',
|
||||
secondValue: '2',
|
||||
},
|
||||
)
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router.output).toEqual({
|
||||
branches: [
|
||||
{
|
||||
branchIndex: 1,
|
||||
branchName: 'Test Branch',
|
||||
evaluation: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip router', async () => {
|
||||
const result = await flowExecutor.execute({
|
||||
action: buildRouterWithOneCondition({ children: [
|
||||
buildCodeAction({ name: 'echo_step', input: {}, skip: true }),
|
||||
], conditions: [
|
||||
{
|
||||
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
|
||||
firstValue: 'test',
|
||||
secondValue: 'test',
|
||||
caseSensitive: false,
|
||||
},
|
||||
], executionType: RouterExecutionType.EXECUTE_FIRST_MATCH, skip: true }), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
|
||||
})
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.router).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { FlowAction, FlowRunStatus } from '@activepieces/shared'
|
||||
import { codeExecutor } from '../../src/lib/handler/code-executor'
|
||||
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
|
||||
import { flowExecutor } from '../../src/lib/handler/flow-executor'
|
||||
import { buildCodeAction, generateMockEngineConstants } from './test-helper'
|
||||
|
||||
describe('codeExecutor', () => {
|
||||
|
||||
it('should execute code that echo parameters action successfully', async () => {
|
||||
const result = await codeExecutor.handle({
|
||||
action: buildCodeAction({
|
||||
name: 'echo_step',
|
||||
input: {
|
||||
'key': '{{ 1 + 2 }}',
|
||||
},
|
||||
}), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
|
||||
})
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.echo_step.output).toEqual({ 'key': 3 })
|
||||
})
|
||||
|
||||
it('should execute code a code that throws an error', async () => {
|
||||
const result = await codeExecutor.handle({
|
||||
action: buildCodeAction({
|
||||
name: 'runtime',
|
||||
input: {},
|
||||
}), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
|
||||
})
|
||||
expect(result.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.FAILED,
|
||||
failedStep: {
|
||||
name: 'runtime',
|
||||
displayName: 'Your Action Name',
|
||||
message: expect.stringContaining('Custom Runtime Error'),
|
||||
},
|
||||
})
|
||||
expect(result.steps.runtime.status).toEqual('FAILED')
|
||||
expect(result.steps.runtime.errorMessage).toContain('Custom Runtime Error')
|
||||
})
|
||||
|
||||
it('should skip code action', async () => {
|
||||
const result = await flowExecutor.execute({
|
||||
action: buildCodeAction({
|
||||
name: 'echo_step',
|
||||
input: {},
|
||||
skip: true,
|
||||
}), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
|
||||
})
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.echo_step).toBeUndefined()
|
||||
})
|
||||
it('should skip flow action', async () => {
|
||||
const flow: FlowAction = {
|
||||
...buildCodeAction({
|
||||
name: 'echo_step',
|
||||
skip: true,
|
||||
input: {},
|
||||
}),
|
||||
nextAction: {
|
||||
...buildCodeAction({
|
||||
name: 'echo_step_1',
|
||||
input: {
|
||||
'key': '{{ 1 + 2 }}',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
const result = await flowExecutor.execute({
|
||||
action: flow, executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
|
||||
})
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.echo_step).toBeUndefined()
|
||||
expect(result.steps.echo_step_1.output).toEqual({ 'key': 3 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
|
||||
import { FlowRunStatus } from '@activepieces/shared'
|
||||
import { codeExecutor } from '../../src/lib/handler/code-executor'
|
||||
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
|
||||
import { pieceExecutor } from '../../src/lib/handler/piece-executor'
|
||||
import { buildCodeAction, buildPieceAction, generateMockEngineConstants } from './test-helper'
|
||||
|
||||
describe('code piece with error handling', () => {
|
||||
|
||||
it('should continue on failure when execute code a code that throws an error', async () => {
|
||||
const result = await codeExecutor.handle({
|
||||
action: buildCodeAction({
|
||||
name: 'runtime',
|
||||
input: {},
|
||||
errorHandlingOptions: {
|
||||
continueOnFailure: {
|
||||
value: true,
|
||||
},
|
||||
retryOnFailure: {
|
||||
value: false,
|
||||
},
|
||||
},
|
||||
}), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
|
||||
})
|
||||
expect(result.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.RUNNING,
|
||||
})
|
||||
expect(result.steps.runtime.status).toEqual('FAILED')
|
||||
expect(result.steps.runtime.errorMessage).toContain('Custom Runtime Error')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('piece with error handling', () => {
|
||||
|
||||
it('should continue on failure when piece fails', async () => {
|
||||
const result = await pieceExecutor.handle({
|
||||
action: buildPieceAction({
|
||||
name: 'send_http',
|
||||
pieceName: '@activepieces/piece-http',
|
||||
actionName: 'send_request',
|
||||
input: {
|
||||
'method': 'POST',
|
||||
'url': 'https://cloud.activepieces.com/api/v1/flags',
|
||||
'headers': {},
|
||||
'queryParams': {},
|
||||
'body_type': 'none',
|
||||
'body': {},
|
||||
},
|
||||
errorHandlingOptions: {
|
||||
continueOnFailure: {
|
||||
value: true,
|
||||
},
|
||||
retryOnFailure: {
|
||||
value: false,
|
||||
},
|
||||
},
|
||||
}), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
|
||||
})
|
||||
|
||||
const expectedError = {
|
||||
response: {
|
||||
status: 404,
|
||||
body: {
|
||||
statusCode: 404,
|
||||
error: 'Not Found',
|
||||
message: 'Route not found',
|
||||
},
|
||||
},
|
||||
request: {},
|
||||
}
|
||||
|
||||
expect(result.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.RUNNING,
|
||||
})
|
||||
expect(result.steps.send_http.status).toBe('FAILED')
|
||||
expect(result.steps.send_http.errorMessage).toEqual(JSON.stringify(expectedError, null, 2))
|
||||
|
||||
}, 10000)
|
||||
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { FlowAction, FlowRunStatus, LoopStepOutput } from '@activepieces/shared'
|
||||
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
|
||||
import { flowExecutor } from '../../src/lib/handler/flow-executor'
|
||||
import { buildCodeAction, buildSimpleLoopAction, generateMockEngineConstants } from './test-helper'
|
||||
|
||||
|
||||
describe('flow with looping', () => {
|
||||
|
||||
it('should execute iterations', async () => {
|
||||
const codeAction = buildCodeAction({
|
||||
name: 'echo_step',
|
||||
input: {
|
||||
'index': '{{loop.index}}',
|
||||
},
|
||||
})
|
||||
const result = await flowExecutor.execute({
|
||||
action: buildSimpleLoopAction({
|
||||
name: 'loop',
|
||||
loopItems: '{{ [4,5,6] }}',
|
||||
firstLoopAction: codeAction,
|
||||
}),
|
||||
executionState: FlowExecutorContext.empty(),
|
||||
constants: generateMockEngineConstants(),
|
||||
})
|
||||
|
||||
const loopOut = result.steps.loop as LoopStepOutput
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(loopOut.output?.iterations.length).toBe(3)
|
||||
expect(loopOut.output?.index).toBe(3)
|
||||
expect(loopOut.output?.item).toBe(6)
|
||||
})
|
||||
|
||||
it('should execute iterations and fail on first iteration', async () => {
|
||||
const generateArray = buildCodeAction({
|
||||
name: 'echo_step',
|
||||
input: {
|
||||
'array': '{{ [4,5,6] }}',
|
||||
},
|
||||
nextAction: buildSimpleLoopAction({
|
||||
name: 'loop',
|
||||
loopItems: '{{ echo_step.array }}',
|
||||
firstLoopAction: buildCodeAction({
|
||||
name: 'runtime',
|
||||
input: {},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
const result = await flowExecutor.execute({
|
||||
action: generateArray,
|
||||
executionState: FlowExecutorContext.empty(),
|
||||
constants: generateMockEngineConstants(),
|
||||
})
|
||||
|
||||
const loopOut = result.steps.loop as LoopStepOutput
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.FAILED)
|
||||
expect(loopOut.output?.iterations.length).toBe(1)
|
||||
expect(loopOut.output?.index).toBe(1)
|
||||
expect(loopOut.output?.item).toBe(4)
|
||||
})
|
||||
|
||||
it('should skip loop', async () => {
|
||||
const result = await flowExecutor.execute({
|
||||
action: buildSimpleLoopAction({ name: 'loop', loopItems: '{{ [4,5,6] }}', skip: true }), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
|
||||
})
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.loop).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should skip loop in flow', async () => {
|
||||
const flow: FlowAction = {
|
||||
...buildSimpleLoopAction({ name: 'loop', loopItems: '{{ [4,5,6] }}', skip: true }),
|
||||
nextAction: {
|
||||
...buildCodeAction({
|
||||
name: 'echo_step',
|
||||
skip: false,
|
||||
input: {
|
||||
'key': '{{ 1 + 2 }}',
|
||||
},
|
||||
}),
|
||||
nextAction: undefined,
|
||||
},
|
||||
}
|
||||
const result = await flowExecutor.execute({
|
||||
action: flow, executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
|
||||
})
|
||||
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
expect(result.steps.loop).toBeUndefined()
|
||||
expect(result.steps.echo_step.output).toEqual({ 'key': 3 })
|
||||
})
|
||||
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { FlowAction, FlowRunStatus } from '@activepieces/shared'
|
||||
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
|
||||
import { flowExecutor } from '../../src/lib/handler/flow-executor'
|
||||
import { pieceExecutor } from '../../src/lib/handler/piece-executor'
|
||||
import { buildPieceAction, generateMockEngineConstants } from './test-helper'
|
||||
|
||||
describe('pieceExecutor', () => {
|
||||
|
||||
it('should execute data mapper successfully', async () => {
|
||||
const result = await pieceExecutor.handle({
|
||||
action: buildPieceAction({
|
||||
name: 'data_mapper',
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
input: {
|
||||
mapping: {
|
||||
'key': '{{ 1 + 2 }}',
|
||||
},
|
||||
},
|
||||
}), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
|
||||
})
|
||||
expect(result.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.RUNNING,
|
||||
})
|
||||
expect(result.steps.data_mapper.output).toEqual({ 'key': 3 })
|
||||
})
|
||||
|
||||
it('should execute fail gracefully when pieces fail', async () => {
|
||||
const result = await pieceExecutor.handle({
|
||||
action: buildPieceAction({
|
||||
name: 'send_http',
|
||||
pieceName: '@activepieces/piece-http',
|
||||
actionName: 'send_request',
|
||||
input: {
|
||||
'url': 'https://cloud.activepieces.com/api/v1/asd',
|
||||
'method': 'GET',
|
||||
'headers': {},
|
||||
'body_type': 'none',
|
||||
'body': {},
|
||||
'queryParams': {},
|
||||
},
|
||||
}), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
|
||||
})
|
||||
|
||||
const expectedError = {
|
||||
response: {
|
||||
status: 404,
|
||||
body: {
|
||||
statusCode: 404,
|
||||
error: 'Not Found',
|
||||
message: 'Route not found',
|
||||
},
|
||||
},
|
||||
request: {},
|
||||
}
|
||||
|
||||
expect(result.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.FAILED,
|
||||
failedStep: {
|
||||
name: 'send_http',
|
||||
displayName: 'Your Action Name',
|
||||
message: JSON.stringify(expectedError, null, 2),
|
||||
},
|
||||
})
|
||||
expect(result.steps.send_http.status).toBe('FAILED')
|
||||
expect(result.steps.send_http.errorMessage).toEqual(JSON.stringify(expectedError, null, 2))
|
||||
}, 10000)
|
||||
it('should skip piece action', async () => {
|
||||
const result = await flowExecutor.execute({
|
||||
action: buildPieceAction({
|
||||
name: 'data_mapper',
|
||||
input: {},
|
||||
skip: true,
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
}), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
|
||||
})
|
||||
expect(result.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.RUNNING,
|
||||
})
|
||||
expect(result.steps.data_mapper).toBeUndefined()
|
||||
})
|
||||
it('should skip piece action in flow', async () => {
|
||||
const flow: FlowAction = {
|
||||
...buildPieceAction({
|
||||
name: 'data_mapper',
|
||||
input: {
|
||||
mapping: {
|
||||
'key': '{{ 1 + 2 }}',
|
||||
},
|
||||
},
|
||||
skip: false,
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
}),
|
||||
nextAction: {
|
||||
...buildPieceAction({
|
||||
name: 'send_http',
|
||||
pieceName: '@activepieces/piece-http',
|
||||
actionName: 'send_request',
|
||||
input: {},
|
||||
skip: true,
|
||||
}),
|
||||
nextAction: undefined,
|
||||
},
|
||||
}
|
||||
const result = await flowExecutor.execute({
|
||||
action: flow, executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
|
||||
})
|
||||
expect(result.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.RUNNING,
|
||||
})
|
||||
expect(result.steps.data_mapper.output).toEqual({ 'key': 3 })
|
||||
expect(result.steps.send_http).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { FlowRunStatus } from '@activepieces/shared'
|
||||
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
|
||||
import { flowExecutor } from '../../src/lib/handler/flow-executor'
|
||||
import { buildPieceAction, generateMockEngineConstants } from './test-helper'
|
||||
|
||||
const failedHttpAction = buildPieceAction({
|
||||
name: 'send_http',
|
||||
pieceName: '@activepieces/piece-http',
|
||||
actionName: 'send_request',
|
||||
input: {
|
||||
'url': 'https://cloud.activepieces.com/api/v1/asd',
|
||||
'method': 'GET',
|
||||
'headers': {},
|
||||
'body_type': 'none',
|
||||
'body': {},
|
||||
'queryParams': {},
|
||||
},
|
||||
})
|
||||
|
||||
const successHttpAction = buildPieceAction({
|
||||
name: 'send_http',
|
||||
pieceName: '@activepieces/piece-http',
|
||||
actionName: 'send_request',
|
||||
input: {
|
||||
'url': 'https://cloud.activepieces.com/api/v1/pieces',
|
||||
'method': 'GET',
|
||||
'headers': {},
|
||||
'body_type': 'none',
|
||||
'body': {},
|
||||
'queryParams': {},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
describe('flow retry', () => {
|
||||
const context = FlowExecutorContext.empty()
|
||||
it('should retry entire flow', async () => {
|
||||
const failedResult = await flowExecutor.execute({
|
||||
action: failedHttpAction, executionState: context, constants: generateMockEngineConstants(),
|
||||
})
|
||||
const retryEntireFlow = await flowExecutor.execute({
|
||||
action: successHttpAction, executionState: context, constants: generateMockEngineConstants(),
|
||||
})
|
||||
expect(failedResult.verdict.status).toBe(FlowRunStatus.FAILED)
|
||||
expect(retryEntireFlow.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
}, 10000)
|
||||
|
||||
it('should retry flow from failed step', async () => {
|
||||
const failedResult = await flowExecutor.execute({
|
||||
action: failedHttpAction, executionState: context, constants: generateMockEngineConstants(),
|
||||
})
|
||||
|
||||
const retryFromFailed = await flowExecutor.execute({
|
||||
action: successHttpAction, executionState: context, constants: generateMockEngineConstants({}),
|
||||
})
|
||||
expect(failedResult.verdict.status).toBe(FlowRunStatus.FAILED)
|
||||
expect(retryFromFailed.verdict.status).toBe(FlowRunStatus.RUNNING)
|
||||
}, 10000)
|
||||
})
|
||||
@@ -0,0 +1,275 @@
|
||||
import { BranchOperator, FlowRunStatus, LoopStepOutput, RouterExecutionType, RouterStepOutput } from '@activepieces/shared'
|
||||
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
|
||||
import { StepExecutionPath } from '../../src/lib/handler/context/step-execution-path'
|
||||
import { flowExecutor } from '../../src/lib/handler/flow-executor'
|
||||
import { buildCodeAction, buildPieceAction, buildRouterWithOneCondition, buildSimpleLoopAction, generateMockEngineConstants } from './test-helper'
|
||||
|
||||
|
||||
const simplePauseFlow = buildPieceAction({
|
||||
name: 'approval',
|
||||
pieceName: '@activepieces/piece-approval',
|
||||
actionName: 'wait_for_approval',
|
||||
input: {},
|
||||
nextAction: buildCodeAction({
|
||||
name: 'echo_step',
|
||||
input: {},
|
||||
}),
|
||||
})
|
||||
|
||||
const flawWithTwoPause = buildPieceAction({
|
||||
name: 'approval',
|
||||
pieceName: '@activepieces/piece-approval',
|
||||
actionName: 'wait_for_approval',
|
||||
input: {},
|
||||
nextAction: buildCodeAction({
|
||||
name: 'echo_step',
|
||||
input: {},
|
||||
nextAction: buildPieceAction({
|
||||
name: 'approval-1',
|
||||
pieceName: '@activepieces/piece-approval',
|
||||
actionName: 'wait_for_approval',
|
||||
input: {},
|
||||
nextAction: buildCodeAction({
|
||||
name: 'echo_step_1',
|
||||
input: {},
|
||||
}),
|
||||
}),
|
||||
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
const pauseFlowWithLoopAndBranch = buildSimpleLoopAction({
|
||||
name: 'loop',
|
||||
loopItems: '{{ [false, true ] }}',
|
||||
firstLoopAction: buildRouterWithOneCondition({
|
||||
conditions: [
|
||||
{
|
||||
operator: BranchOperator.BOOLEAN_IS_TRUE,
|
||||
firstValue: '{{ loop.item }}',
|
||||
},
|
||||
|
||||
],
|
||||
executionType: RouterExecutionType.EXECUTE_FIRST_MATCH,
|
||||
children: [
|
||||
simplePauseFlow,
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
describe('flow with pause', () => {
|
||||
|
||||
it('should pause and resume successfully with loops and branch', async () => {
|
||||
const pauseResult = await flowExecutor.execute({
|
||||
action: pauseFlowWithLoopAndBranch,
|
||||
executionState: FlowExecutorContext.empty().setPauseRequestId('requestId'),
|
||||
constants: generateMockEngineConstants(),
|
||||
})
|
||||
expect(pauseResult.verdict).toEqual({
|
||||
status: FlowRunStatus.PAUSED,
|
||||
pauseMetadata: {
|
||||
response: {},
|
||||
requestId: 'requestId',
|
||||
requestIdToReply: undefined,
|
||||
'type': 'WEBHOOK',
|
||||
},
|
||||
})
|
||||
expect(Object.keys(pauseResult.steps)).toEqual(['loop'])
|
||||
|
||||
// Verify that the first iteration (true) triggered the branch condition
|
||||
const loopOutputBeforeResume = pauseResult.steps.loop as LoopStepOutput
|
||||
expect(loopOutputBeforeResume.output?.iterations.length).toBe(2)
|
||||
expect(loopOutputBeforeResume.output?.item).toBe(true)
|
||||
expect(Object.keys(loopOutputBeforeResume.output?.iterations[0] ?? {})).toContain('router')
|
||||
|
||||
|
||||
const resumeResultTwo = await flowExecutor.execute({
|
||||
action: pauseFlowWithLoopAndBranch,
|
||||
executionState: pauseResult.setCurrentPath(StepExecutionPath.empty()).setVerdict({
|
||||
status: FlowRunStatus.RUNNING,
|
||||
}),
|
||||
constants: generateMockEngineConstants({
|
||||
resumePayload: {
|
||||
queryParams: {
|
||||
action: 'approve',
|
||||
},
|
||||
body: {},
|
||||
headers: {},
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
expect(resumeResultTwo.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.RUNNING,
|
||||
},
|
||||
)
|
||||
expect(Object.keys(resumeResultTwo.steps)).toEqual(['loop'])
|
||||
|
||||
const loopOut = resumeResultTwo.steps.loop as LoopStepOutput
|
||||
expect(Object.keys(loopOut.output?.iterations[1] ?? {})).toEqual(['router', 'approval', 'echo_step'])
|
||||
expect((loopOut.output?.iterations[0].router as RouterStepOutput).output?.branches[0].evaluation).toBe(false)
|
||||
expect((loopOut.output?.iterations[1].router as RouterStepOutput).output?.branches[0].evaluation).toBe(true)
|
||||
|
||||
|
||||
})
|
||||
|
||||
it('should pause and resume with two different steps in same flow successfully', async () => {
|
||||
const pauseResult1 = await flowExecutor.execute({
|
||||
action: flawWithTwoPause,
|
||||
executionState: FlowExecutorContext.empty().setPauseRequestId('requestId'),
|
||||
constants: generateMockEngineConstants(),
|
||||
})
|
||||
const resumeResult1 = await flowExecutor.execute({
|
||||
action: flawWithTwoPause,
|
||||
executionState: pauseResult1,
|
||||
constants: generateMockEngineConstants({
|
||||
resumePayload: {
|
||||
queryParams: {
|
||||
action: 'approve',
|
||||
},
|
||||
body: {},
|
||||
headers: {},
|
||||
},
|
||||
}),
|
||||
})
|
||||
expect(resumeResult1.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.PAUSED,
|
||||
pauseMetadata: {
|
||||
response: {},
|
||||
requestId: 'requestId',
|
||||
requestIdToReply: undefined,
|
||||
'type': 'WEBHOOK',
|
||||
},
|
||||
})
|
||||
const resumeResult2 = await flowExecutor.execute({
|
||||
action: flawWithTwoPause,
|
||||
executionState: resumeResult1.setVerdict({
|
||||
status: FlowRunStatus.RUNNING,
|
||||
}),
|
||||
constants: generateMockEngineConstants({
|
||||
resumePayload: {
|
||||
queryParams: {
|
||||
action: 'approve',
|
||||
},
|
||||
body: {},
|
||||
headers: {},
|
||||
},
|
||||
}),
|
||||
})
|
||||
expect(resumeResult2.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.RUNNING,
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
|
||||
it('should pause and resume successfully', async () => {
|
||||
const pauseResult = await flowExecutor.execute({
|
||||
action: simplePauseFlow,
|
||||
executionState: FlowExecutorContext.empty().setPauseRequestId('requestId'),
|
||||
constants: generateMockEngineConstants(),
|
||||
})
|
||||
expect(pauseResult.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.PAUSED,
|
||||
pauseMetadata: {
|
||||
response: {},
|
||||
requestId: 'requestId',
|
||||
requestIdToReply: undefined,
|
||||
'type': 'WEBHOOK',
|
||||
},
|
||||
})
|
||||
const currentState = pauseResult.currentState()
|
||||
expect(Object.keys(currentState).length).toBe(1)
|
||||
|
||||
const resumeResult = await flowExecutor.execute({
|
||||
action: simplePauseFlow,
|
||||
executionState: pauseResult,
|
||||
constants: generateMockEngineConstants({
|
||||
resumePayload: {
|
||||
queryParams: {
|
||||
action: 'approve',
|
||||
},
|
||||
body: {},
|
||||
headers: {},
|
||||
},
|
||||
}),
|
||||
})
|
||||
expect(resumeResult.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.RUNNING,
|
||||
})
|
||||
expect(resumeResult.currentState()).toEqual({
|
||||
'approval': {
|
||||
approved: true,
|
||||
},
|
||||
echo_step: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('should pause at most one action when router has multiple branches with pause actions', async () => {
|
||||
const routerWithTwoPauseActions = buildRouterWithOneCondition({
|
||||
conditions: [
|
||||
{
|
||||
operator: BranchOperator.BOOLEAN_IS_TRUE,
|
||||
firstValue: 'true',
|
||||
},
|
||||
{
|
||||
operator: BranchOperator.BOOLEAN_IS_TRUE,
|
||||
firstValue: 'true',
|
||||
},
|
||||
],
|
||||
executionType: RouterExecutionType.EXECUTE_ALL_MATCH,
|
||||
children: [
|
||||
buildPieceAction({
|
||||
name: 'approval_1',
|
||||
pieceName: '@activepieces/piece-approval',
|
||||
actionName: 'wait_for_approval',
|
||||
input: {},
|
||||
nextAction: buildCodeAction({
|
||||
name: 'echo_step',
|
||||
input: {},
|
||||
}),
|
||||
}),
|
||||
buildPieceAction({
|
||||
name: 'approval_2',
|
||||
pieceName: '@activepieces/piece-approval',
|
||||
actionName: 'wait_for_approval',
|
||||
input: {},
|
||||
nextAction: buildCodeAction({
|
||||
name: 'echo_step_1',
|
||||
input: {},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const result = await flowExecutor.execute({
|
||||
action: routerWithTwoPauseActions,
|
||||
executionState: FlowExecutorContext.empty().setPauseRequestId('requestId'),
|
||||
constants: generateMockEngineConstants(),
|
||||
})
|
||||
|
||||
expect(result.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.PAUSED,
|
||||
pauseMetadata: {
|
||||
response: {},
|
||||
requestId: 'requestId',
|
||||
requestIdToReply: undefined,
|
||||
'type': 'WEBHOOK',
|
||||
},
|
||||
})
|
||||
|
||||
const routerOutput = result.steps.router as RouterStepOutput
|
||||
expect(routerOutput).toBeDefined()
|
||||
expect(routerOutput.output).toBeDefined()
|
||||
|
||||
const executedBranches = routerOutput.output?.branches?.filter((branch) => branch.evaluation === true)
|
||||
expect(executedBranches).toHaveLength(2)
|
||||
|
||||
expect(result.steps.approval_1).toBeDefined()
|
||||
expect(result.steps.approval_1.status).toBe('PAUSED')
|
||||
expect(result.steps.approval_2).toBeUndefined()
|
||||
|
||||
expect(Object.keys(result.steps)).toEqual(['router', 'approval_1'])
|
||||
})
|
||||
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { FlowRunStatus } from '@activepieces/shared'
|
||||
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
|
||||
import { flowExecutor } from '../../src/lib/handler/flow-executor'
|
||||
import { buildPieceAction, generateMockEngineConstants } from './test-helper'
|
||||
|
||||
describe('flow with response', () => {
|
||||
|
||||
it('should execute return response successfully', async () => {
|
||||
const input = {
|
||||
responseType: 'json',
|
||||
fields: {
|
||||
status: 200,
|
||||
headers: {
|
||||
'random': 'header',
|
||||
},
|
||||
body: {
|
||||
'hello': 'world',
|
||||
},
|
||||
},
|
||||
respond: 'stop',
|
||||
}
|
||||
const response = {
|
||||
status: 200,
|
||||
headers: {
|
||||
'random': 'header',
|
||||
},
|
||||
body: {
|
||||
'hello': 'world',
|
||||
},
|
||||
}
|
||||
|
||||
const result = await flowExecutor.execute({
|
||||
action: buildPieceAction({
|
||||
name: 'http',
|
||||
pieceName: '@activepieces/piece-webhook',
|
||||
actionName: 'return_response',
|
||||
input,
|
||||
}), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
|
||||
})
|
||||
expect(result.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.SUCCEEDED,
|
||||
stopResponse: response,
|
||||
})
|
||||
expect(result.steps.http.output).toEqual(response)
|
||||
})
|
||||
|
||||
})
|
||||
433
activepieces-fork/packages/engine/test/handler/router-branching.test.ts
Executable file
433
activepieces-fork/packages/engine/test/handler/router-branching.test.ts
Executable file
@@ -0,0 +1,433 @@
|
||||
import { BranchCondition, BranchOperator, FlowAction, FlowRunStatus, RouterExecutionType } from '@activepieces/shared'
|
||||
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
|
||||
import { flowExecutor } from '../../src/lib/handler/flow-executor'
|
||||
import { buildCodeAction, buildPieceAction, buildRouterWithOneCondition, generateMockEngineConstants } from './test-helper'
|
||||
|
||||
function executeRouterActionWithOneCondition(children: FlowAction[], conditions: (BranchCondition | null)[], executionType: RouterExecutionType): Promise<FlowExecutorContext> {
|
||||
return flowExecutor.execute({
|
||||
action: buildRouterWithOneCondition({
|
||||
children,
|
||||
conditions,
|
||||
executionType,
|
||||
}),
|
||||
executionState: FlowExecutorContext.empty(),
|
||||
constants: generateMockEngineConstants(),
|
||||
})
|
||||
}
|
||||
describe('router with branching different conditions', () => {
|
||||
it('should execute router with the first matching condition', async () => {
|
||||
const result = await executeRouterActionWithOneCondition([
|
||||
buildPieceAction({
|
||||
name: 'data_mapper',
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
input: {
|
||||
mapping: {
|
||||
'key': '{{ 1 + 2 }}',
|
||||
},
|
||||
},
|
||||
}),
|
||||
buildPieceAction({
|
||||
name: 'data_mapper_1',
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
input: {
|
||||
mapping: {
|
||||
'key': '{{ 1 + 2 }}',
|
||||
},
|
||||
},
|
||||
}),
|
||||
], [
|
||||
{
|
||||
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
|
||||
firstValue: 'test',
|
||||
secondValue: 'test',
|
||||
caseSensitive: false,
|
||||
},
|
||||
{
|
||||
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
|
||||
firstValue: 'test',
|
||||
secondValue: 'anything',
|
||||
caseSensitive: false,
|
||||
},
|
||||
], RouterExecutionType.EXECUTE_FIRST_MATCH)
|
||||
|
||||
expect(result.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.RUNNING,
|
||||
})
|
||||
expect(result.steps.data_mapper.output).toEqual({ 'key': 3 })
|
||||
expect(result.steps.data_mapper_1).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should execute router with the all matching conditions', async () => {
|
||||
const result = await executeRouterActionWithOneCondition([
|
||||
buildPieceAction({
|
||||
name: 'data_mapper',
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
input: {
|
||||
mapping: {
|
||||
'key': '{{ 1 + 2 }}',
|
||||
},
|
||||
},
|
||||
}),
|
||||
buildPieceAction({
|
||||
name: 'data_mapper_1',
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
input: {
|
||||
mapping: {
|
||||
'key': '{{ 1 + 2 }}',
|
||||
},
|
||||
},
|
||||
}),
|
||||
], [
|
||||
{
|
||||
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
|
||||
firstValue: 'test',
|
||||
secondValue: 'test',
|
||||
caseSensitive: false,
|
||||
},
|
||||
{
|
||||
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
|
||||
firstValue: 'test',
|
||||
secondValue: 'test',
|
||||
caseSensitive: false,
|
||||
},
|
||||
], RouterExecutionType.EXECUTE_ALL_MATCH)
|
||||
|
||||
expect(result.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.RUNNING,
|
||||
})
|
||||
expect(result.steps.data_mapper.output).toEqual({ 'key': 3 })
|
||||
expect(result.steps.data_mapper_1.output).toEqual({ 'key': 3 })
|
||||
})
|
||||
|
||||
it('should execute router but no branch will match', async () => {
|
||||
const result = await executeRouterActionWithOneCondition([
|
||||
buildPieceAction({
|
||||
name: 'data_mapper',
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
input: {
|
||||
mapping: {
|
||||
'key': '{{ 1 + 2 }}',
|
||||
},
|
||||
},
|
||||
}),
|
||||
buildPieceAction({
|
||||
name: 'data_mapper_1',
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
input: {
|
||||
mapping: {
|
||||
'key': '{{ 1 + 5 }}',
|
||||
},
|
||||
},
|
||||
}),
|
||||
], [
|
||||
{
|
||||
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
|
||||
firstValue: 'abc',
|
||||
secondValue: 'test',
|
||||
caseSensitive: false,
|
||||
},
|
||||
{
|
||||
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
|
||||
firstValue: 'test',
|
||||
secondValue: 'fasc',
|
||||
caseSensitive: false,
|
||||
},
|
||||
], RouterExecutionType.EXECUTE_ALL_MATCH)
|
||||
|
||||
expect(result.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.RUNNING,
|
||||
})
|
||||
const routerOutput = result.steps.router.output as { branches: boolean[] }
|
||||
expect(routerOutput.branches).toEqual([
|
||||
{
|
||||
branchName: 'Test Branch',
|
||||
branchIndex: 1,
|
||||
evaluation: false,
|
||||
},
|
||||
{
|
||||
branchName: 'Test Branch',
|
||||
branchIndex: 2,
|
||||
evaluation: false,
|
||||
},
|
||||
])
|
||||
expect(result.steps.data_mapper).toBeUndefined()
|
||||
expect(result.steps.data_mapper_1).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should execute fallback branch with first match execution type', async () => {
|
||||
const result = await executeRouterActionWithOneCondition([
|
||||
buildPieceAction({
|
||||
name: 'data_mapper',
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
input: {
|
||||
mapping: {
|
||||
'key': '{{ 1 + 2 }}',
|
||||
},
|
||||
},
|
||||
}),
|
||||
buildPieceAction({
|
||||
name: 'data_mapper_1',
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
input: {
|
||||
mapping: {
|
||||
'key': '{{ 1 + 5 }}',
|
||||
},
|
||||
},
|
||||
}),
|
||||
buildPieceAction({
|
||||
name: 'fallback_mapper',
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
input: {
|
||||
mapping: {
|
||||
'key': '{{ 1 + 10 }}',
|
||||
},
|
||||
},
|
||||
}),
|
||||
], [
|
||||
{
|
||||
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
|
||||
firstValue: 'abc',
|
||||
secondValue: 'test',
|
||||
caseSensitive: false,
|
||||
},
|
||||
{
|
||||
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
|
||||
firstValue: 'test',
|
||||
secondValue: 'fasc',
|
||||
caseSensitive: false,
|
||||
},
|
||||
null, // Fallback branch
|
||||
], RouterExecutionType.EXECUTE_FIRST_MATCH)
|
||||
|
||||
expect(result.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.RUNNING,
|
||||
})
|
||||
expect(result.steps.data_mapper).toBeUndefined()
|
||||
expect(result.steps.data_mapper_1).toBeUndefined()
|
||||
expect(result.steps.fallback_mapper.output).toEqual({ 'key': 11 })
|
||||
})
|
||||
|
||||
it('should execute fallback branch with all match execution type', async () => {
|
||||
const result = await executeRouterActionWithOneCondition([
|
||||
buildPieceAction({
|
||||
name: 'data_mapper',
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
input: {
|
||||
mapping: {
|
||||
'key': '{{ 1 + 2 }}',
|
||||
},
|
||||
},
|
||||
}),
|
||||
buildPieceAction({
|
||||
name: 'data_mapper_1',
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
input: {
|
||||
mapping: {
|
||||
'key': '{{ 1 + 5 }}',
|
||||
},
|
||||
},
|
||||
}),
|
||||
buildPieceAction({
|
||||
name: 'fallback_mapper',
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
input: {
|
||||
mapping: {
|
||||
'key': '{{ 1 + 10 }}',
|
||||
},
|
||||
},
|
||||
}),
|
||||
], [
|
||||
{
|
||||
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
|
||||
firstValue: 'abc',
|
||||
secondValue: 'test',
|
||||
caseSensitive: false,
|
||||
},
|
||||
{
|
||||
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
|
||||
firstValue: 'test',
|
||||
secondValue: 'fasc',
|
||||
caseSensitive: false,
|
||||
},
|
||||
null, // Fallback branch
|
||||
], RouterExecutionType.EXECUTE_ALL_MATCH)
|
||||
|
||||
expect(result.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.RUNNING,
|
||||
})
|
||||
expect(result.steps.data_mapper).toBeUndefined()
|
||||
expect(result.steps.data_mapper_1).toBeUndefined()
|
||||
expect(result.steps.fallback_mapper.output).toEqual({ 'key': 11 })
|
||||
})
|
||||
|
||||
it('should not execute fallback branch when there is a matching condition in EXECUTE_FIRST_MATCH mode', async () => {
|
||||
const result = await executeRouterActionWithOneCondition([
|
||||
buildPieceAction({
|
||||
name: 'data_mapper',
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
input: {
|
||||
mapping: {
|
||||
'key': '{{ 1 + 2 }}',
|
||||
},
|
||||
},
|
||||
}),
|
||||
buildPieceAction({
|
||||
name: 'fallback_mapper',
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
input: {
|
||||
mapping: {
|
||||
'key': '{{ 1 + 10 }}',
|
||||
},
|
||||
},
|
||||
}),
|
||||
], [
|
||||
{
|
||||
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
|
||||
firstValue: 'test',
|
||||
secondValue: 'test',
|
||||
caseSensitive: false,
|
||||
},
|
||||
null, // Fallback branch
|
||||
], RouterExecutionType.EXECUTE_FIRST_MATCH)
|
||||
|
||||
expect(result.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.RUNNING,
|
||||
})
|
||||
expect(result.steps.data_mapper.output).toEqual({ 'key': 3 })
|
||||
expect(result.steps.fallback_mapper).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not execute fallback branch when there is a matching condition in EXECUTE_ALL_MATCH mode', async () => {
|
||||
const result = await executeRouterActionWithOneCondition([
|
||||
buildPieceAction({
|
||||
name: 'data_mapper',
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
input: {
|
||||
mapping: {
|
||||
'key': '{{ 1 + 2 }}',
|
||||
},
|
||||
},
|
||||
}),
|
||||
buildPieceAction({
|
||||
name: 'data_mapper_1',
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
input: {
|
||||
mapping: {
|
||||
'key': '{{ 1 + 5 }}',
|
||||
},
|
||||
},
|
||||
}),
|
||||
buildPieceAction({
|
||||
name: 'fallback_mapper',
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
input: {
|
||||
mapping: {
|
||||
'key': '{{ 1 + 10 }}',
|
||||
},
|
||||
},
|
||||
}),
|
||||
], [
|
||||
{
|
||||
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
|
||||
firstValue: 'test',
|
||||
secondValue: 'test',
|
||||
caseSensitive: false,
|
||||
},
|
||||
{
|
||||
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
|
||||
firstValue: 'test',
|
||||
secondValue: 'test',
|
||||
caseSensitive: false,
|
||||
},
|
||||
null, // Fallback branch
|
||||
], RouterExecutionType.EXECUTE_ALL_MATCH)
|
||||
|
||||
expect(result.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.RUNNING,
|
||||
})
|
||||
expect(result.steps.data_mapper.output).toEqual({ 'key': 3 })
|
||||
expect(result.steps.data_mapper_1.output).toEqual({ 'key': 6 })
|
||||
expect(result.steps.fallback_mapper).toBeUndefined()
|
||||
})
|
||||
it('should skip router', async () => {
|
||||
const result = await flowExecutor.execute({
|
||||
action: buildRouterWithOneCondition({ children: [
|
||||
buildPieceAction({
|
||||
name: 'data_mapper',
|
||||
skip: true,
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
input: {},
|
||||
}),
|
||||
], conditions: [
|
||||
{
|
||||
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
|
||||
firstValue: 'test',
|
||||
secondValue: 'test',
|
||||
caseSensitive: false,
|
||||
},
|
||||
], executionType: RouterExecutionType.EXECUTE_FIRST_MATCH, skip: true }), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
|
||||
})
|
||||
expect(result.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.RUNNING,
|
||||
})
|
||||
expect(result.steps.router).toBeUndefined()
|
||||
})
|
||||
it('should skip router action in flow', async () => {
|
||||
const router: FlowAction = {
|
||||
...buildRouterWithOneCondition({ children: [
|
||||
buildPieceAction({
|
||||
name: 'data_mapper',
|
||||
skip: true,
|
||||
pieceName: '@activepieces/piece-data-mapper',
|
||||
actionName: 'advanced_mapping',
|
||||
input: {},
|
||||
}),
|
||||
], conditions: [
|
||||
{
|
||||
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
|
||||
firstValue: 'test',
|
||||
secondValue: 'test',
|
||||
caseSensitive: false,
|
||||
},
|
||||
],
|
||||
executionType: RouterExecutionType.EXECUTE_FIRST_MATCH,
|
||||
skip: true }),
|
||||
nextAction: {
|
||||
...buildCodeAction({
|
||||
name: 'echo_step',
|
||||
skip: false,
|
||||
input: {
|
||||
'key': '{{ 1 + 2 }}',
|
||||
},
|
||||
}),
|
||||
nextAction: undefined,
|
||||
},
|
||||
}
|
||||
const result = await flowExecutor.execute({
|
||||
action: router, executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
|
||||
})
|
||||
expect(result.verdict).toStrictEqual({
|
||||
status: FlowRunStatus.RUNNING,
|
||||
})
|
||||
expect(result.steps.router).toBeUndefined()
|
||||
expect(result.steps.echo_step.output).toEqual({ 'key': 3 })
|
||||
})
|
||||
})
|
||||
122
activepieces-fork/packages/engine/test/handler/test-helper.ts
Normal file
122
activepieces-fork/packages/engine/test/handler/test-helper.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { ActionErrorHandlingOptions, BranchCondition, BranchExecutionType, CodeAction, FlowAction, FlowActionType, FlowVersionState, LoopOnItemsAction, PieceAction, ProgressUpdateType, PropertyExecutionType, RouterExecutionType, RunEnvironment } from '@activepieces/shared'
|
||||
import { EngineConstants } from '../../src/lib/handler/context/engine-constants'
|
||||
|
||||
export const generateMockEngineConstants = (params?: Partial<EngineConstants>): EngineConstants => {
|
||||
return new EngineConstants(
|
||||
{
|
||||
platformId: params?.platformId ?? 'platformId',
|
||||
timeoutInSeconds: params?.timeoutInSeconds ?? 10,
|
||||
flowId: params?.flowId ?? 'flowId',
|
||||
flowVersionId: params?.flowVersionId ?? 'flowVersionId',
|
||||
flowVersionState: params?.flowVersionState ?? FlowVersionState.DRAFT,
|
||||
flowRunId: params?.flowRunId ?? 'flowRunId',
|
||||
publicApiUrl: params?.publicApiUrl ?? 'http://127.0.0.1:4200/api/',
|
||||
internalApiUrl: params?.internalApiUrl ?? 'http://127.0.0.1:3000/',
|
||||
retryConstants: params?.retryConstants ?? {
|
||||
maxAttempts: 2,
|
||||
retryExponential: 1,
|
||||
retryInterval: 1,
|
||||
},
|
||||
engineToken: params?.engineToken ?? 'engineToken',
|
||||
projectId: params?.projectId ?? 'projectId',
|
||||
triggerPieceName: params?.triggerPieceName ?? 'mcp-trigger-piece-name',
|
||||
progressUpdateType: params?.progressUpdateType ?? ProgressUpdateType.NONE,
|
||||
serverHandlerId: params?.serverHandlerId ?? null,
|
||||
httpRequestId: params?.httpRequestId ?? null,
|
||||
resumePayload: params?.resumePayload,
|
||||
runEnvironment: params?.runEnvironment ?? RunEnvironment.TESTING,
|
||||
stepNameToTest: params?.stepNameToTest ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
export function buildSimpleLoopAction({
|
||||
name,
|
||||
loopItems,
|
||||
firstLoopAction,
|
||||
skip,
|
||||
}: {
|
||||
name: string
|
||||
loopItems: string
|
||||
firstLoopAction?: FlowAction
|
||||
skip?: boolean
|
||||
}): LoopOnItemsAction {
|
||||
return {
|
||||
name,
|
||||
displayName: 'Loop',
|
||||
type: FlowActionType.LOOP_ON_ITEMS,
|
||||
skip: skip ?? false,
|
||||
settings: {
|
||||
items: loopItems,
|
||||
},
|
||||
firstLoopAction,
|
||||
valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRouterWithOneCondition({ children, conditions, executionType, skip }: { children: FlowAction[], conditions: (BranchCondition | null)[], executionType: RouterExecutionType, skip?: boolean }): FlowAction {
|
||||
return {
|
||||
name: 'router',
|
||||
displayName: 'Your Router Name',
|
||||
type: FlowActionType.ROUTER,
|
||||
skip: skip ?? false,
|
||||
settings: {
|
||||
branches: conditions.map((condition) => {
|
||||
if (condition === null) {
|
||||
return {
|
||||
branchType: BranchExecutionType.FALLBACK,
|
||||
branchName: 'Fallback Branch',
|
||||
}
|
||||
}
|
||||
return {
|
||||
conditions: [[condition]],
|
||||
branchType: BranchExecutionType.CONDITION,
|
||||
branchName: 'Test Branch',
|
||||
}
|
||||
}),
|
||||
executionType,
|
||||
},
|
||||
children,
|
||||
valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCodeAction({ name, input, skip, nextAction, errorHandlingOptions }: { name: 'echo_step' | 'runtime' | 'echo_step_1', input: Record<string, unknown>, skip?: boolean, errorHandlingOptions?: ActionErrorHandlingOptions, nextAction?: FlowAction }): CodeAction {
|
||||
return {
|
||||
name,
|
||||
displayName: 'Your Action Name',
|
||||
type: FlowActionType.CODE,
|
||||
skip: skip ?? false,
|
||||
settings: {
|
||||
input,
|
||||
sourceCode: {
|
||||
packageJson: '',
|
||||
code: '',
|
||||
},
|
||||
errorHandlingOptions,
|
||||
},
|
||||
nextAction,
|
||||
valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPieceAction({ name, input, skip, pieceName, actionName, nextAction, errorHandlingOptions }: { errorHandlingOptions?: ActionErrorHandlingOptions, name: string, input: Record<string, unknown>, skip?: boolean, pieceName: string, actionName: string, nextAction?: FlowAction }): PieceAction {
|
||||
return {
|
||||
name,
|
||||
displayName: 'Your Action Name',
|
||||
type: FlowActionType.PIECE,
|
||||
skip: skip ?? false,
|
||||
settings: {
|
||||
input,
|
||||
pieceName,
|
||||
pieceVersion: '1.0.0', // Not required since it's running in development mode
|
||||
actionName,
|
||||
propertySettings: Object.fromEntries(Object.entries(input).map(([key]) => [key, {
|
||||
type: PropertyExecutionType.MANUAL,
|
||||
schema: undefined,
|
||||
}])),
|
||||
errorHandlingOptions,
|
||||
},
|
||||
nextAction,
|
||||
valid: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { FlowRunStatus } from '@activepieces/shared'
|
||||
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
|
||||
import { runWithExponentialBackoff } from '../../src/lib/helper/error-handling'
|
||||
import { buildCodeAction, generateMockEngineConstants } from '../handler/test-helper'
|
||||
|
||||
describe('runWithExponentialBackoff', () => {
|
||||
const executionState = FlowExecutorContext.empty()
|
||||
const action = buildCodeAction({
|
||||
name: 'runtime',
|
||||
input: {},
|
||||
errorHandlingOptions: {
|
||||
continueOnFailure: {
|
||||
value: false,
|
||||
},
|
||||
retryOnFailure: {
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
const constants = generateMockEngineConstants()
|
||||
const requestFunction = jest.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return resultExecutionState when verdict is not FAILED', async () => {
|
||||
const resultExecutionState = FlowExecutorContext.empty().setVerdict({
|
||||
status: FlowRunStatus.SUCCEEDED,
|
||||
stopResponse: undefined,
|
||||
})
|
||||
requestFunction.mockResolvedValue(resultExecutionState)
|
||||
|
||||
const output = await runWithExponentialBackoff(executionState, action, constants, requestFunction)
|
||||
|
||||
expect(output).toEqual(resultExecutionState)
|
||||
expect(requestFunction).toHaveBeenCalledWith({ action, executionState, constants })
|
||||
})
|
||||
|
||||
|
||||
it('should retry and return resultExecutionState when verdict is FAILED and retry is enabled', async () => {
|
||||
const resultExecutionState = FlowExecutorContext.empty().setVerdict({
|
||||
status: FlowRunStatus.FAILED,
|
||||
failedStep: {
|
||||
name: 'runtime',
|
||||
displayName: 'runtime',
|
||||
message: 'Custom Runtime Error',
|
||||
},
|
||||
})
|
||||
|
||||
requestFunction.mockResolvedValue(resultExecutionState)
|
||||
|
||||
const output = await runWithExponentialBackoff(executionState, action, constants, requestFunction)
|
||||
|
||||
expect(output).toEqual(resultExecutionState)
|
||||
// Mock applies for the first attempt and second attempt is a real call which return success
|
||||
expect(requestFunction).toHaveBeenCalledTimes(2)
|
||||
expect(requestFunction).toHaveBeenCalledWith({ action, executionState, constants })
|
||||
expect(requestFunction).toHaveBeenCalledWith({ action, executionState, constants })
|
||||
})
|
||||
|
||||
it('should not retry and return resultExecutionState when verdict is FAILED but retry is disabled', async () => {
|
||||
const resultExecutionState = FlowExecutorContext.empty().setVerdict({
|
||||
status: FlowRunStatus.FAILED,
|
||||
failedStep: {
|
||||
name: 'runtime',
|
||||
displayName: 'runtime',
|
||||
message: 'Custom Runtime Error',
|
||||
},
|
||||
})
|
||||
|
||||
requestFunction.mockResolvedValue(resultExecutionState)
|
||||
|
||||
|
||||
const actionWithDisabledRetry = buildCodeAction({
|
||||
name: 'runtime',
|
||||
input: {},
|
||||
errorHandlingOptions: {
|
||||
continueOnFailure: {
|
||||
value: false,
|
||||
},
|
||||
retryOnFailure: {
|
||||
value: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const output = await runWithExponentialBackoff(executionState, actionWithDisabledRetry, constants, requestFunction)
|
||||
|
||||
expect(output).toEqual(resultExecutionState)
|
||||
expect(requestFunction).toHaveBeenCalledTimes(1)
|
||||
expect(requestFunction).toHaveBeenCalledWith({ action: actionWithDisabledRetry, executionState, constants })
|
||||
|
||||
})
|
||||
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
FlowActionType,
|
||||
GenericStepOutput,
|
||||
StepOutputStatus,
|
||||
} from '@activepieces/shared'
|
||||
import { loggingUtils } from '../../src/lib/helper/logging-utils'
|
||||
|
||||
describe('Logging Utils', () => {
|
||||
it('Should not truncate whole step if its log size exceeds limit', async () => {
|
||||
const steps = {
|
||||
mockStep: GenericStepOutput.create({
|
||||
type: FlowActionType.CODE,
|
||||
status: StepOutputStatus.SUCCEEDED,
|
||||
input: {
|
||||
a: 'a'.repeat(1024 * 1024 * 12),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
// act
|
||||
const result = await loggingUtils.trimExecution(steps)
|
||||
|
||||
// assert
|
||||
expect((result.mockStep.input as Record<string, string>).a.length).toBeLessThan(1024 * 1024 * 12)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
code: async (params) => {
|
||||
return params;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
code: async (params) => {
|
||||
return params;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
code: async (params) => {
|
||||
throw new Error('Custom Runtime Error');
|
||||
}
|
||||
};
|
||||
|
||||
753
activepieces-fork/packages/engine/test/services/props-resolver.test.ts
Executable file
753
activepieces-fork/packages/engine/test/services/props-resolver.test.ts
Executable file
@@ -0,0 +1,753 @@
|
||||
import { ApFile, LATEST_CONTEXT_VERSION, PieceAuth, Property } from '@activepieces/pieces-framework'
|
||||
import { FlowActionType, FlowTriggerType, GenericStepOutput, PropertyExecutionType, PropertySettings, StepOutputStatus } from '@activepieces/shared'
|
||||
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
|
||||
import { StepExecutionPath } from '../../src/lib/handler/context/step-execution-path'
|
||||
import { propsProcessor } from '../../src/lib/variables/props-processor'
|
||||
import { createPropsResolver } from '../../src/lib/variables/props-resolver'
|
||||
|
||||
const propsResolverService = createPropsResolver({
|
||||
projectId: 'PROJECT_ID',
|
||||
engineToken: 'WORKER_TOKEN',
|
||||
apiUrl: 'http://127.0.0.1:3000',
|
||||
contextVersion: LATEST_CONTEXT_VERSION,
|
||||
})
|
||||
|
||||
const executionState = FlowExecutorContext.empty()
|
||||
.upsertStep(
|
||||
'trigger',
|
||||
GenericStepOutput.create({
|
||||
type: FlowTriggerType.PIECE,
|
||||
status: StepOutputStatus.SUCCEEDED,
|
||||
input: {},
|
||||
output: {
|
||||
items: [5, 'a'],
|
||||
name: 'John',
|
||||
price: 6.4,
|
||||
users: [
|
||||
{
|
||||
name: 'Alice',
|
||||
},
|
||||
{
|
||||
name: 'Bob',
|
||||
},
|
||||
],
|
||||
lastNames: [
|
||||
'Smith',
|
||||
'Doe',
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
.upsertStep('step_1',
|
||||
GenericStepOutput.create({
|
||||
|
||||
type: FlowActionType.PIECE,
|
||||
status: StepOutputStatus.SUCCEEDED,
|
||||
input: {},
|
||||
output: {
|
||||
success: true,
|
||||
},
|
||||
}))
|
||||
.upsertStep('step_2', GenericStepOutput.create({
|
||||
type: FlowActionType.PIECE,
|
||||
status: StepOutputStatus.SUCCEEDED,
|
||||
input: {},
|
||||
output: 'memory://{"fileName":"hello.png","data":"iVBORw0KGgoAAAANSUhEUgAAAiAAAAC4CAYAAADaI1cbAAA0h0lEQVR4AezdA5AlPx7A8Zxt27Z9r5PB2SidWTqbr26S9Hr/tm3btu3723eDJD3r15ec17vzXr+Z"}',
|
||||
}))
|
||||
|
||||
|
||||
|
||||
describe('Props resolver', () => {
|
||||
|
||||
|
||||
test('Test resolve inside nested loops', async () => {
|
||||
|
||||
const modifiedExecutionState = executionState.upsertStep('step_3', GenericStepOutput.create({
|
||||
type: FlowActionType.LOOP_ON_ITEMS,
|
||||
status: StepOutputStatus.SUCCEEDED,
|
||||
input: {},
|
||||
output: {
|
||||
iterations: [
|
||||
{
|
||||
'step_8': GenericStepOutput.create({
|
||||
type: FlowActionType.PIECE,
|
||||
status: StepOutputStatus.SUCCEEDED,
|
||||
input: {},
|
||||
output: {
|
||||
delayForInMs: 20000,
|
||||
success: true,
|
||||
},
|
||||
}),
|
||||
'step_4': GenericStepOutput.create({
|
||||
type: FlowActionType.LOOP_ON_ITEMS,
|
||||
status: StepOutputStatus.SUCCEEDED,
|
||||
input: {},
|
||||
output: {
|
||||
iterations: [
|
||||
{
|
||||
'step_7': GenericStepOutput.create({
|
||||
'type': FlowActionType.PIECE,
|
||||
'status': StepOutputStatus.SUCCEEDED,
|
||||
'input': {
|
||||
'unit': 'seconds',
|
||||
'delayFor': '20',
|
||||
},
|
||||
'output': {
|
||||
'delayForInMs': 20000,
|
||||
'success': true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
item: 1,
|
||||
index: 0,
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
item: 1,
|
||||
index: 0,
|
||||
},
|
||||
})).setCurrentPath(StepExecutionPath.empty()
|
||||
.loopIteration({
|
||||
loopName: 'step_3',
|
||||
iteration: 0,
|
||||
})
|
||||
.loopIteration({
|
||||
loopName: 'step_4',
|
||||
iteration: 0,
|
||||
}),
|
||||
)
|
||||
|
||||
const { resolvedInput: secondLevelResolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{step_7.delayForInMs}}', executionState: modifiedExecutionState })
|
||||
expect(secondLevelResolvedInput).toEqual(20000)
|
||||
const { resolvedInput: firstLevelResolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{step_8.delayForInMs}}', executionState: modifiedExecutionState })
|
||||
expect(firstLevelResolvedInput).toEqual(20000)
|
||||
|
||||
})
|
||||
test('Test resolve text with no variables', async () => {
|
||||
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: 'Hello world!', executionState })
|
||||
expect(resolvedInput).toEqual(
|
||||
'Hello world!',
|
||||
)
|
||||
})
|
||||
|
||||
test('Test resolve text with double variables', async () => {
|
||||
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: 'Price is {{ trigger.price }}', executionState })
|
||||
expect(resolvedInput,
|
||||
).toEqual('Price is 6.4')
|
||||
})
|
||||
|
||||
|
||||
test('Test resolve object steps variables', async () => {
|
||||
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{ {"where": "a"} }}', executionState })
|
||||
expect(resolvedInput).toEqual(
|
||||
{
|
||||
where: 'a',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('Test resolve object steps variables', async () => {
|
||||
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{trigger}}', executionState })
|
||||
expect(resolvedInput).toEqual(
|
||||
{
|
||||
items: [5, 'a'],
|
||||
name: 'John',
|
||||
price: 6.4,
|
||||
users: [
|
||||
{
|
||||
name: 'Alice',
|
||||
},
|
||||
{
|
||||
name: 'Bob',
|
||||
},
|
||||
],
|
||||
lastNames: [
|
||||
'Smith',
|
||||
'Doe',
|
||||
],
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('flatten array path', async () => {
|
||||
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{flattenNestedKeys(trigger, [\'users\',\'name\'])}}', executionState })
|
||||
expect(resolvedInput).toEqual(['Alice', 'Bob'])
|
||||
})
|
||||
|
||||
test('merge multiple flatten array paths', async ()=>{
|
||||
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{flattenNestedKeys(trigger, [\'users\',\'name\'])}} {{trigger.lastNames}}', executionState })
|
||||
expect(resolvedInput).toEqual(['Alice Smith', 'Bob Doe'])
|
||||
})
|
||||
|
||||
test('Test resolve steps variables', async () => {
|
||||
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{trigger.name}}', executionState })
|
||||
expect(resolvedInput).toEqual(
|
||||
'John',
|
||||
)
|
||||
})
|
||||
|
||||
test('Test resolve multiple variables', async () => {
|
||||
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{trigger.name}} {{trigger.name}}', executionState })
|
||||
expect(
|
||||
resolvedInput,
|
||||
).toEqual('John John')
|
||||
})
|
||||
|
||||
test('Test resolve variable array items', async () => {
|
||||
const { resolvedInput } = await propsResolverService.resolve({
|
||||
unresolvedInput:
|
||||
'{{trigger.items[0]}} {{trigger.items[1]}}',
|
||||
executionState,
|
||||
})
|
||||
expect(
|
||||
resolvedInput,
|
||||
).toEqual('5 a')
|
||||
})
|
||||
|
||||
test('Test resolve array variable', async () => {
|
||||
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{trigger.items}}', executionState })
|
||||
expect(resolvedInput).toEqual(
|
||||
[5, 'a'],
|
||||
)
|
||||
})
|
||||
|
||||
test('Test resolve integer from variables', async () => {
|
||||
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{trigger.items[0]}}', executionState })
|
||||
expect(
|
||||
resolvedInput,
|
||||
).toEqual(5)
|
||||
})
|
||||
|
||||
test('Test resolve text with undefined variables', async () => {
|
||||
const { resolvedInput } = await propsResolverService.resolve({
|
||||
unresolvedInput:
|
||||
'test {{configs.bar}} {{trigger.items[4]}}',
|
||||
executionState,
|
||||
})
|
||||
expect(
|
||||
resolvedInput,
|
||||
).toEqual('test ')
|
||||
})
|
||||
|
||||
test('Test resolve empty text', async () => {
|
||||
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '', executionState })
|
||||
expect(resolvedInput).toEqual('')
|
||||
})
|
||||
|
||||
|
||||
test('Test resolve empty variable operator', async () => {
|
||||
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{}}', executionState })
|
||||
expect(resolvedInput).toEqual('')
|
||||
})
|
||||
|
||||
test('Test resolve object', async () => {
|
||||
const { resolvedInput } = await propsResolverService.resolve({
|
||||
unresolvedInput:
|
||||
{
|
||||
input: {
|
||||
foo: 'bar',
|
||||
nums: [1, 2, '{{trigger.items[0]}}'],
|
||||
var: '{{trigger.price}}',
|
||||
},
|
||||
},
|
||||
executionState,
|
||||
})
|
||||
expect(
|
||||
resolvedInput,
|
||||
).toEqual({ input: { foo: 'bar', nums: [1, 2, 5], var: 6.4 } })
|
||||
})
|
||||
|
||||
test('Test resolve boolean from variables', async () => {
|
||||
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{step_1.success}}', executionState })
|
||||
expect(resolvedInput).toEqual(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('Test resolve addition from variables', async () => {
|
||||
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{trigger.price + 2 - 3}}', executionState })
|
||||
expect(resolvedInput).toEqual(
|
||||
6.4 + 2 - 3,
|
||||
)
|
||||
})
|
||||
|
||||
test('Test resolve text with array variable', async () => {
|
||||
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: 'items are {{trigger.items}}', executionState })
|
||||
expect(
|
||||
resolvedInput,
|
||||
).toEqual('items are [5,"a"]')
|
||||
})
|
||||
|
||||
test('Test resolve text with object variable', async () => {
|
||||
const { resolvedInput } = await propsResolverService.resolve({
|
||||
unresolvedInput:
|
||||
'values from trigger step: {{trigger}}',
|
||||
executionState,
|
||||
})
|
||||
expect(
|
||||
resolvedInput,
|
||||
).toEqual('values from trigger step: {"items":[5,"a"],"name":"John","price":6.4,"users":[{"name":"Alice"},{"name":"Bob"}],"lastNames":["Smith","Doe"]}')
|
||||
})
|
||||
|
||||
test('Test use built-in Math Min function', async () => {
|
||||
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{Math.min(trigger.price + 2 - 3, 2)}}', executionState })
|
||||
expect(resolvedInput).toEqual(
|
||||
2,
|
||||
)
|
||||
})
|
||||
|
||||
test('Test use built-in Math Max function', async () => {
|
||||
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{Math.max(trigger.price + 2, 2)}}', executionState })
|
||||
expect(resolvedInput).toEqual(
|
||||
8.4,
|
||||
)
|
||||
})
|
||||
|
||||
it('should not compress memory file in native value in non-logs mode', async () => {
|
||||
const input = {
|
||||
base64: 'memory://{"fileName":"hello.png","data":"iVBORw0KGgoAAAANSUhEUgAAAiAAAAC4CAYAAADaI1cbAAA0h0lEQVR4AezdA5AlPx7A8Zxt27Z9r5PB2SidWTqbr26S9Hr/tm3btu3723eDJD3r15ec17vzXr+Z"}',
|
||||
}
|
||||
const { resolvedInput } = await propsResolverService.resolve({
|
||||
unresolvedInput: input,
|
||||
executionState,
|
||||
})
|
||||
expect(resolvedInput).toEqual({
|
||||
base64: 'memory://{"fileName":"hello.png","data":"iVBORw0KGgoAAAANSUhEUgAAAiAAAAC4CAYAAADaI1cbAAA0h0lEQVR4AezdA5AlPx7A8Zxt27Z9r5PB2SidWTqbr26S9Hr/tm3btu3723eDJD3r15ec17vzXr+Z"}',
|
||||
})
|
||||
})
|
||||
|
||||
it('should not compress memory file in referenced value in non-logs mode', async () => {
|
||||
const input = {
|
||||
base64: '{{step_2}}',
|
||||
}
|
||||
const { resolvedInput } = await propsResolverService.resolve({
|
||||
unresolvedInput: input,
|
||||
executionState,
|
||||
})
|
||||
expect(resolvedInput).toEqual({
|
||||
base64: 'memory://{"fileName":"hello.png","data":"iVBORw0KGgoAAAANSUhEUgAAAiAAAAC4CAYAAADaI1cbAAA0h0lEQVR4AezdA5AlPx7A8Zxt27Z9r5PB2SidWTqbr26S9Hr/tm3btu3723eDJD3r15ec17vzXr+Z"}',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
it('should return base64 from base64 with mime only', async () => {
|
||||
const input = {
|
||||
base64WithMime: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAiAAAAC4CAYAAADaI1cbAAA0h0lEQVR4AezdA5AlPx7A8Zxt27Z9r5PB2SidWTqbr26S9Hr/tm3btu3723eDJD3r15ec17vzXr+Z',
|
||||
base64: 'iVBORw0KGgoAAAANSUhEUgAAAiAAAAC4CAYAAADaI1cbAAA0h0lEQVR4AezdA5AlPx7A8Zxt27Z9r5PB2SidWTqbr26S9Hr/tm3btu3723eDJD3r15ec17vzXr+Z',
|
||||
}
|
||||
const props = {
|
||||
base64WithMime: Property.File({
|
||||
displayName: 'Base64',
|
||||
required: true,
|
||||
}),
|
||||
base64: Property.File({
|
||||
displayName: 'Base64',
|
||||
required: true,
|
||||
}),
|
||||
}
|
||||
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, {})
|
||||
expect(processedInput).toEqual({
|
||||
base64: null,
|
||||
base64WithMime: new ApFile('unknown.png', Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAiAAAAC4CAYAAADaI1cbAAA0h0lEQVR4AezdA5AlPx7A8Zxt27Z9r5PB2SidWTqbr26S9Hr/tm3btu3723eDJD3r15ec17vzXr+Z', 'base64'), 'png'),
|
||||
})
|
||||
expect(errors).toEqual({
|
||||
'base64': [
|
||||
'Expected file url or base64 with mimeType, received: iVBORw0KGgoAAAANSUhEUgAAAiAAAAC4CAYAAADaI1cbAAA0h0lEQVR4AezdA5AlPx7A8Zxt27Z9r5PB2SidWTqbr26S9Hr/tm3btu3723eDJD3r15ec17vzXr+Z',
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should resolve files inside the array properties', async () => {
|
||||
const input = {
|
||||
documents: [
|
||||
{
|
||||
file: 'https://cdn.activepieces.com/brand/logo.svg?token=123',
|
||||
},
|
||||
],
|
||||
}
|
||||
const props = {
|
||||
documents: Property.Array({
|
||||
displayName: 'Documents',
|
||||
required: true,
|
||||
properties: {
|
||||
file: Property.File({
|
||||
displayName: 'File',
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, {})
|
||||
expect(processedInput.documents[0].file).toBeDefined()
|
||||
expect(processedInput.documents[0].file.extension).toBe('svg')
|
||||
expect(processedInput.documents[0].file.filename).toBe('logo.svg')
|
||||
expect(errors).toEqual({})
|
||||
})
|
||||
|
||||
|
||||
|
||||
it('should return error for invalid file inside the array properties', async () => {
|
||||
const input = {
|
||||
documents: [
|
||||
{
|
||||
file: 'invalid-url',
|
||||
},
|
||||
],
|
||||
}
|
||||
const props = {
|
||||
documents: Property.Array({
|
||||
displayName: 'Documents',
|
||||
required: true,
|
||||
properties: {
|
||||
file: Property.File({
|
||||
displayName: 'File',
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, {})
|
||||
expect(processedInput.documents[0].file).toBeNull()
|
||||
expect(errors).toEqual({
|
||||
'documents': {
|
||||
properties: [{
|
||||
file: [
|
||||
'Expected file url or base64 with mimeType, received: invalid-url',
|
||||
],
|
||||
}],
|
||||
},
|
||||
})
|
||||
})
|
||||
it('should return images for image url', async () => {
|
||||
const input = {
|
||||
file: 'https://cdn.activepieces.com/brand/logo.svg?token=123',
|
||||
}
|
||||
const props = {
|
||||
file: Property.File({
|
||||
displayName: 'File',
|
||||
required: true,
|
||||
}),
|
||||
|
||||
}
|
||||
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, {})
|
||||
expect(processedInput.file).toBeDefined()
|
||||
expect(processedInput.file.extension).toBe('svg')
|
||||
expect(processedInput.file.filename).toBe('logo.svg')
|
||||
expect(errors).toEqual({})
|
||||
})
|
||||
|
||||
// Test with invalid url
|
||||
it('should return error for invalid data', async () => {
|
||||
const input = {
|
||||
file: 'https://google.com',
|
||||
nullFile: null,
|
||||
nullOptionalFile: null,
|
||||
}
|
||||
const props = {
|
||||
file: Property.File({
|
||||
displayName: 'File',
|
||||
required: true,
|
||||
}),
|
||||
nullFile: Property.File({
|
||||
displayName: 'File',
|
||||
required: true,
|
||||
}),
|
||||
nullOptionalFile: Property.File({
|
||||
displayName: 'File',
|
||||
required: false,
|
||||
}),
|
||||
}
|
||||
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, {})
|
||||
|
||||
expect(processedInput.file).toBeDefined()
|
||||
expect(processedInput.file.extension).toBe('html')
|
||||
expect(processedInput.file.filename).toBe('unknown.html')
|
||||
expect(processedInput.nullFile).toBeNull()
|
||||
expect(processedInput.nullOptionalFile).toBeNull()
|
||||
|
||||
expect(errors).toEqual({
|
||||
'nullFile': [
|
||||
'Expected file url or base64 with mimeType, received: null',
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
it('should return casted number for text', async () => {
|
||||
const input = {
|
||||
price: '0',
|
||||
auth: {
|
||||
age: '12',
|
||||
},
|
||||
}
|
||||
|
||||
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, {
|
||||
price: Property.Number({
|
||||
displayName: 'Price',
|
||||
required: true,
|
||||
}),
|
||||
}, PieceAuth.CustomAuth({
|
||||
required: true,
|
||||
props: {
|
||||
age: Property.Number({
|
||||
displayName: 'age',
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
}), true, {})
|
||||
|
||||
expect(processedInput).toEqual({
|
||||
auth: {
|
||||
age: 12,
|
||||
},
|
||||
price: 0,
|
||||
})
|
||||
expect(errors).toEqual({})
|
||||
})
|
||||
|
||||
it('should not error if auth configured, but no auth provided in input', async () => {
|
||||
const input = {
|
||||
price: '0',
|
||||
}
|
||||
const props = {
|
||||
price: Property.Number({
|
||||
displayName: 'Price',
|
||||
required: true,
|
||||
}),
|
||||
}
|
||||
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.CustomAuth({
|
||||
required: true,
|
||||
props: {},
|
||||
}), false, {})
|
||||
|
||||
expect(processedInput).toEqual({
|
||||
price: 0,
|
||||
})
|
||||
expect(errors).toEqual({})
|
||||
})
|
||||
|
||||
|
||||
it('should flatten arrays inside DYNAMIC properties', async () => {
|
||||
const input = {
|
||||
dynamicProp: {
|
||||
items: {
|
||||
id: [1, 2],
|
||||
name: ['Item 1', 'Item 2'],
|
||||
},
|
||||
},
|
||||
}
|
||||
const propertySettings: Record<string, PropertySettings> = {
|
||||
dynamicProp: {
|
||||
type: PropertyExecutionType.MANUAL,
|
||||
schema: {
|
||||
items: Property.Array({
|
||||
displayName: 'Items',
|
||||
required: true,
|
||||
properties: {
|
||||
id: Property.Number({
|
||||
displayName: 'ID',
|
||||
required: true,
|
||||
}),
|
||||
name: Property.LongText({
|
||||
displayName: 'Name',
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
const props = {
|
||||
dynamicProp: Property.DynamicProperties({
|
||||
auth: undefined,
|
||||
displayName: 'Dynamic Property',
|
||||
required: true,
|
||||
props: async () => {
|
||||
return {}
|
||||
},
|
||||
refreshers: [],
|
||||
}),
|
||||
}
|
||||
|
||||
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, propertySettings)
|
||||
|
||||
expect(processedInput.dynamicProp.items).toEqual([
|
||||
{ id: 1, name: 'Item 1' },
|
||||
{ id: 2, name: 'Item 2' },
|
||||
])
|
||||
expect(errors).toEqual({})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('Array Flatter Processor', () => {
|
||||
it('should flatten array of objects', async () => {
|
||||
const input = {
|
||||
items: {
|
||||
id: [1, 2],
|
||||
name: ['Item 1', 'Item 2'],
|
||||
},
|
||||
}
|
||||
const props = {
|
||||
items: Property.Array({
|
||||
displayName: 'Items',
|
||||
required: true,
|
||||
properties: {
|
||||
id: Property.Number({
|
||||
displayName: 'ID',
|
||||
required: true,
|
||||
}),
|
||||
name: Property.LongText({
|
||||
displayName: 'Name',
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, {})
|
||||
|
||||
expect(processedInput.items).toEqual([
|
||||
{ id: 1, name: 'Item 1' },
|
||||
{ id: 2, name: 'Item 2' },
|
||||
])
|
||||
expect(errors).toEqual({})
|
||||
})
|
||||
|
||||
it('should handle non-array properties gracefully', async () => {
|
||||
const input = {
|
||||
items: {
|
||||
id: [1, 2],
|
||||
name: 'Single Item', // Non-array property
|
||||
},
|
||||
}
|
||||
const props = {
|
||||
items: Property.Array({
|
||||
displayName: 'Items',
|
||||
required: true,
|
||||
properties: {
|
||||
id: Property.Number({
|
||||
displayName: 'ID',
|
||||
required: true,
|
||||
}),
|
||||
name: Property.LongText({
|
||||
displayName: 'Name',
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, {})
|
||||
|
||||
expect(processedInput.items).toEqual([
|
||||
{ id: 1, name: 'Single Item' },
|
||||
{ id: 2, name: 'Single Item' },
|
||||
])
|
||||
expect(errors).toEqual({})
|
||||
})
|
||||
|
||||
it('should handle arrays of unequal length', async () => {
|
||||
const input = {
|
||||
items: {
|
||||
id: [1, 2, 3], // Longer array
|
||||
name: ['Item 1', 'Item 2'], // Shorter array
|
||||
},
|
||||
}
|
||||
const props = {
|
||||
items: Property.Array({
|
||||
displayName: 'Items',
|
||||
required: true,
|
||||
properties: {
|
||||
id: Property.Number({
|
||||
displayName: 'ID',
|
||||
required: true,
|
||||
}),
|
||||
name: Property.LongText({
|
||||
displayName: 'Name',
|
||||
required: false,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, {})
|
||||
|
||||
expect(processedInput.items).toEqual([
|
||||
{ id: 1, name: 'Item 1' },
|
||||
{ id: 2, name: 'Item 2' },
|
||||
{ id: 3, name: undefined }, // Handle missing name
|
||||
])
|
||||
expect(errors).toEqual({})
|
||||
})
|
||||
|
||||
it('should convert number to string for ShortText properties', async () => {
|
||||
const input = {
|
||||
items: {
|
||||
id: 123,
|
||||
name: 'Item Name',
|
||||
},
|
||||
}
|
||||
const props = {
|
||||
items: Property.Array({
|
||||
displayName: 'Items',
|
||||
required: true,
|
||||
properties: {
|
||||
id: Property.ShortText({
|
||||
displayName: 'ID',
|
||||
required: true,
|
||||
}),
|
||||
name: Property.LongText({
|
||||
displayName: 'Name',
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, {})
|
||||
|
||||
expect(processedInput.items).toEqual([
|
||||
{ id: '123', name: 'Item Name' },
|
||||
])
|
||||
expect(errors).toEqual({})
|
||||
})
|
||||
|
||||
|
||||
it('should handle arrays with string values', async () => {
|
||||
const input = {
|
||||
items: {
|
||||
id: '1',
|
||||
name: 'item1',
|
||||
},
|
||||
}
|
||||
const props = {
|
||||
items: Property.Array({
|
||||
displayName: 'Items',
|
||||
required: true,
|
||||
properties: {
|
||||
id: Property.ShortText({
|
||||
displayName: 'ID',
|
||||
required: true,
|
||||
}),
|
||||
name: Property.LongText({
|
||||
displayName: 'Name',
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, {})
|
||||
|
||||
expect(processedInput.items).toEqual([
|
||||
{ id: '1', name: 'item1' },
|
||||
])
|
||||
expect(errors).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,342 @@
|
||||
import { PieceAuth, Property } from '@activepieces/pieces-framework'
|
||||
import { propsProcessor } from '../../src/lib/variables/props-processor'
|
||||
describe('Property Validation', () => {
|
||||
describe('required properties', () => {
|
||||
it('should validate required string property', async () => {
|
||||
const props = {
|
||||
text: Property.ShortText({
|
||||
displayName: 'Text',
|
||||
required: true,
|
||||
}),
|
||||
}
|
||||
|
||||
const { errors: validErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{ text: 'valid text' },
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(validErrors).toEqual({})
|
||||
|
||||
const { errors: nullErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{ text: null },
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(nullErrors).toEqual({
|
||||
text: ['Expected string, received: null'],
|
||||
})
|
||||
|
||||
const { errors: undefinedErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{ text: undefined },
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(undefinedErrors).toEqual({
|
||||
text: ['Expected string, received: undefined'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should validate required number property', async () => {
|
||||
const props = {
|
||||
number: Property.Number({
|
||||
displayName: 'Number',
|
||||
required: true,
|
||||
}),
|
||||
}
|
||||
|
||||
const { errors: validErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{ number: 42 },
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(validErrors).toEqual({})
|
||||
|
||||
const { errors: nullErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{ number: null },
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(nullErrors).toEqual({
|
||||
number: ['Expected number, received: null'],
|
||||
})
|
||||
|
||||
const { errors: typeErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{ number: 'not a number' },
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(typeErrors).toEqual({
|
||||
number: ['Expected number, received: not a number'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should validate required datetime property', async () => {
|
||||
const props = {
|
||||
date: Property.DateTime({
|
||||
displayName: 'DateTime',
|
||||
required: true,
|
||||
}),
|
||||
}
|
||||
|
||||
const { errors: validErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{ date: '2024-03-14T12:00:00.000Z' },
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(validErrors).toEqual({})
|
||||
|
||||
const { errors: invalidErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{ date: 'not a date' },
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(invalidErrors).toEqual({
|
||||
date: ['Invalid datetime format. Expected ISO format (e.g. 2024-03-14T12:00:00.000Z), received: not a date'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should validate required array property', async () => {
|
||||
const props = {
|
||||
array: Property.Array({
|
||||
displayName: 'Array',
|
||||
required: true,
|
||||
}),
|
||||
}
|
||||
|
||||
const { errors: validErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{ array: [1, 2, 3] },
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(validErrors).toEqual({})
|
||||
|
||||
const { errors: typeErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{ array: 'not an array' },
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(typeErrors).toEqual({
|
||||
array: ['Expected array, received: not an array'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should validate required json property', async () => {
|
||||
const props = {
|
||||
json: Property.Json({
|
||||
displayName: 'JSON',
|
||||
required: true,
|
||||
}),
|
||||
}
|
||||
|
||||
const { errors: validErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{ json: { key: 'value' } },
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(validErrors).toEqual({})
|
||||
|
||||
const { errors: validJsonStringErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{ json: '{"key": "value"}' },
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(validJsonStringErrors).toEqual({})
|
||||
|
||||
const { errors: validArrayErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{ json: [1, 2, 3] },
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(validArrayErrors).toEqual({})
|
||||
|
||||
const { errors: validArrayStringErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{ json: '[1, 2, 3]' },
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(validArrayStringErrors).toEqual({})
|
||||
|
||||
const { errors: invalidJsonErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{ json: 'not a json object' },
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(invalidJsonErrors).toEqual({
|
||||
json: ['Expected JSON, received: not a json object'],
|
||||
})
|
||||
|
||||
const { errors: nullErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{ json: null },
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(nullErrors).toEqual({
|
||||
json: ['Expected JSON, received: null'],
|
||||
})
|
||||
})
|
||||
it('should validate required object property', async () => {
|
||||
const props = {
|
||||
object: Property.Object({
|
||||
displayName: 'Object',
|
||||
required: true,
|
||||
}),
|
||||
}
|
||||
|
||||
const { errors: validErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{ object: { key: 'value' } },
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(validErrors).toEqual({})
|
||||
|
||||
const { errors: nullErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{ object: null },
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(nullErrors).toEqual({
|
||||
object: ['Expected object, received: null'],
|
||||
})
|
||||
|
||||
const { errors: typeErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{ object: 'not an object' },
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(typeErrors).toEqual({
|
||||
object: ['Expected object, received: not an object'],
|
||||
})
|
||||
|
||||
const { errors: jsonStringErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{ object: JSON.stringify({ key: 'value' }) },
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(jsonStringErrors).toEqual({})
|
||||
|
||||
const { errors: undefinedErrors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{ object: { key: 'value' } },
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(undefinedErrors).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('optional properties', () => {
|
||||
it('should validate optional properties', async () => {
|
||||
const props = {
|
||||
text: Property.ShortText({
|
||||
displayName: 'Text',
|
||||
required: false,
|
||||
}),
|
||||
number: Property.Number({
|
||||
displayName: 'Number',
|
||||
required: false,
|
||||
}),
|
||||
}
|
||||
|
||||
const { errors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{
|
||||
text: null,
|
||||
number: undefined,
|
||||
},
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
expect(errors).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('type validation', () => {
|
||||
it('should validate property types', async () => {
|
||||
const props = {
|
||||
string: Property.ShortText({
|
||||
displayName: 'Text',
|
||||
required: true,
|
||||
}),
|
||||
number: Property.Number({
|
||||
displayName: 'Number',
|
||||
required: true,
|
||||
}),
|
||||
boolean: Property.Checkbox({
|
||||
displayName: 'Checkbox',
|
||||
required: true,
|
||||
}),
|
||||
array: Property.Array({
|
||||
displayName: 'Array',
|
||||
required: true,
|
||||
}),
|
||||
object: Property.Object({
|
||||
displayName: 'Object',
|
||||
required: true,
|
||||
}),
|
||||
}
|
||||
|
||||
const { errors } = await propsProcessor.applyProcessorsAndValidators(
|
||||
{
|
||||
string: 42,
|
||||
number: 'not a number',
|
||||
boolean: 'not a boolean',
|
||||
array: 'not an array',
|
||||
object: 'not an object',
|
||||
},
|
||||
props,
|
||||
PieceAuth.None(),
|
||||
false,
|
||||
{},
|
||||
)
|
||||
|
||||
expect(errors).toEqual({
|
||||
number: ['Expected number, received: not a number'],
|
||||
boolean: ['Expected boolean, received: not a boolean'],
|
||||
array: ['Expected array, received: not an array'],
|
||||
object: ['Expected object, received: not an object'],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
24
activepieces-fork/packages/engine/tsconfig.json
Normal file
24
activepieces-fork/packages/engine/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"allowJs": true,
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json",
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
activepieces-fork/packages/engine/tsconfig.lib.json
Normal file
10
activepieces-fork/packages/engine/tsconfig.lib.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
16
activepieces-fork/packages/engine/tsconfig.spec.json
Normal file
16
activepieces-fork/packages/engine/tsconfig.spec.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts",
|
||||
"test/**/*.ts",
|
||||
"test/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
14
activepieces-fork/packages/engine/webpack.config.js
Normal file
14
activepieces-fork/packages/engine/webpack.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const { composePlugins, withNx } = require('@nx/webpack');
|
||||
const IgnoreDynamicRequire = require('webpack-ignore-dynamic-require');
|
||||
|
||||
module.exports = composePlugins(withNx(), (config) => {
|
||||
config.plugins.push(new IgnoreDynamicRequire());
|
||||
|
||||
config.externals = {
|
||||
'isolated-vm': 'commonjs2 isolated-vm',
|
||||
'utf-8-validate': 'commonjs2 utf-8-validate',
|
||||
'bufferutil': 'commonjs2 bufferutil'
|
||||
};
|
||||
|
||||
return config;
|
||||
});
|
||||
Reference in New Issue
Block a user