feat(workflow): expected output schema for runtime-output steps + validation (#21744)
## Summary Extends the workflow validation layer (introduced in #21422) and adds a new "expected output schema" capability for steps whose output structure is only known at runtime. Some workflow steps (HTTP Request, Code, Logic Function, AI Agent (coming soon), Webhook trigger) don't have a statically known output shape, so downstream steps can't resolve `{{step.x.y}}` variable paths or validate them. This PR lets users declare a **sample/expected output** for those steps, derives an output schema from it, and uses that schema both to power variable resolution and to surface validation issues at build time. ## What's included ### Expected output schema (shared schemas + types) - New `expectedOutputSchemaShape` reused across the HTTP request, code, logic function and AI agent action settings schemas, plus the webhook trigger schema (`expectedOutputSchema` optional loose object). - Mirrored on the server-side action/trigger settings types. ### Output schema computation (server) - `workflow-schema.workspace-service` now computes a step's output schema from the user-declared `expectedOutputSchema` sample (via `getOutputSchemaFromValue`) when no statically computed schema is available. ### Validation layer (server) - `STEP_HAS_NO_VARIABLE_REFERENCE` (warning): flags steps of `VARIABLE_CONSUMING_ACTION_TYPES` (HTTP_REQUEST, CODE, LOGIC_FUNCTION, SEND_EMAIL, record CRUD) that reference no upstream variable. - `LOGIC_FUNCTION_OUTPUT_SCHEMA_MISMATCH` / `AI_AGENT_OUTPUT_SCHEMA_MISMATCH` (warnings): compare the declared output schema against the expected sample using the new shared `getOutputSchemaMismatchIssues` util (missing keys, leaf/object mismatches, type mismatches). - Trigger is now validated alongside steps (trigger type requirements + trigger variable references). - Validation issues no longer return both `suggestions` and `availablePaths` when they are identical (avoids redundant, costly payloads). ### Shared utilities - New `getOutputSchemaMismatchIssues` (+ tests) in `twenty-shared/logic-function`. - Moved `agentResponseSchemaToOutputSchema` from `twenty-front` into `twenty-shared/ai` so it can be reused on both sides. ### Frontend - New `WorkflowExpectedOutputBodyInput` component (JSON sample editor with validation) used by HTTP request, code, logic function and AI agent step editors. - New `resolvePersistedStepOutputSchema` util + `useStepsOutputSchema` update: resolves a step's output schema from `outputSchema`, falling back to `expectedOutputSchema`, with an AI_AGENT default. - HTTP request / code / logic function editors persist `expectedOutputSchema` and derive `outputSchema` from it. - Webhook trigger default settings include `expectedOutputSchema`. BONUS : iterator loop validation <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21744?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+60
@@ -0,0 +1,60 @@
|
||||
import { FormRawJsonFieldInput } from '@/object-record/record-field/ui/form-types/components/FormRawJsonFieldInput';
|
||||
import { parseAndValidateVariableFriendlyStringifiedJson } from '@/workflow/utils/parseAndValidateVariableFriendlyStringifiedJson';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type WorkflowExpectedOutputBodyInputProps = {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
defaultValue: object | undefined;
|
||||
readonly?: boolean;
|
||||
onChange: (parsedValue: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
export const WorkflowExpectedOutputBodyInput = ({
|
||||
label,
|
||||
placeholder,
|
||||
defaultValue,
|
||||
readonly,
|
||||
onChange,
|
||||
}: WorkflowExpectedOutputBodyInputProps) => {
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [errorVisible, setErrorVisible] = useState(false);
|
||||
|
||||
const handleChange = (value: string | null) => {
|
||||
if (readonly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsingResult = parseAndValidateVariableFriendlyStringifiedJson(
|
||||
isNonEmptyString(value) ? value : '{}',
|
||||
);
|
||||
|
||||
if (!parsingResult.isValid) {
|
||||
setError(parsingResult.error);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setError(undefined);
|
||||
onChange(parsingResult.data);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormRawJsonFieldInput
|
||||
label={label ?? t`Expected Output Body`}
|
||||
placeholder={placeholder ?? t`Enter a JSON object`}
|
||||
error={errorVisible ? error : undefined}
|
||||
onBlur={() => setErrorVisible(true)}
|
||||
readonly={readonly}
|
||||
defaultValue={
|
||||
isDefined(defaultValue) && Object.keys(defaultValue).length > 0
|
||||
? JSON.stringify(defaultValue, null, 2)
|
||||
: null
|
||||
}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+22
@@ -16,6 +16,7 @@ import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTab
|
||||
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { WorkflowExpectedOutputBodyInput } from '@/workflow/workflow-steps/components/WorkflowExpectedOutputBodyInput';
|
||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||
import { WorkflowStepCmdEnterButton } from '@/workflow/workflow-steps/components/WorkflowStepCmdEnterButton';
|
||||
import { WorkflowCodeEditor } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowCodeEditor';
|
||||
@@ -205,6 +206,22 @@ export const WorkflowEditActionCode = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleExpectedOutputBodyChange = (
|
||||
parsedValue: Record<string, unknown>,
|
||||
) => {
|
||||
if (actionOptions.readonly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateAction({
|
||||
...action,
|
||||
settings: {
|
||||
...action.settings,
|
||||
expectedOutputSchema: parsedValue,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleTestInputChange = async (value: any, path: string[]) => {
|
||||
if (actionOptions.readonly === true) {
|
||||
return;
|
||||
@@ -402,6 +419,11 @@ export const WorkflowEditActionCode = ({
|
||||
readonly={actionOptions.readonly}
|
||||
onEnterFullScreen={handleEnterFullScreen}
|
||||
/>
|
||||
<WorkflowExpectedOutputBodyInput
|
||||
defaultValue={action.settings.expectedOutputSchema}
|
||||
onChange={handleExpectedOutputBodyChange}
|
||||
readonly={actionOptions.readonly}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{activeTabId === WorkflowLogicFunctionTabId.TEST && (
|
||||
|
||||
+30
-11
@@ -1,10 +1,11 @@
|
||||
import { type WorkflowHttpRequestAction } from '@/workflow/types/Workflow';
|
||||
import { type BaseOutputSchemaV2 } from 'twenty-shared/workflow';
|
||||
import { parseAndValidateVariableFriendlyStringifiedJson } from '@/workflow/utils/parseAndValidateVariableFriendlyStringifiedJson';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useState } from 'react';
|
||||
import { convertOutputSchemaToJson } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/convertOutputSchemaToJson';
|
||||
import { getHttpRequestOutputSchema } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/getHttpRequestOutputSchema';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type BaseOutputSchemaV2 } from 'twenty-shared/workflow';
|
||||
|
||||
type UseHttpRequestOutputSchemaProps = {
|
||||
action: WorkflowHttpRequestAction;
|
||||
@@ -12,20 +13,37 @@ type UseHttpRequestOutputSchemaProps = {
|
||||
readonly?: boolean;
|
||||
};
|
||||
|
||||
const getInitialExpectedBody = (
|
||||
action: WorkflowHttpRequestAction,
|
||||
): object | undefined => {
|
||||
const expectedOutputSchema = action.settings.expectedOutputSchema;
|
||||
|
||||
if (
|
||||
isDefined(expectedOutputSchema) &&
|
||||
Object.keys(expectedOutputSchema).length
|
||||
) {
|
||||
return expectedOutputSchema;
|
||||
}
|
||||
|
||||
if (Object.keys(action.settings.outputSchema).length) {
|
||||
return convertOutputSchemaToJson(
|
||||
action.settings.outputSchema as BaseOutputSchemaV2,
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const useHttpRequestOutputSchema = ({
|
||||
action,
|
||||
onActionUpdate,
|
||||
readonly,
|
||||
}: UseHttpRequestOutputSchemaProps) => {
|
||||
const initialExpectedBody = getInitialExpectedBody(action);
|
||||
|
||||
const [outputSchema, setOutputSchema] = useState<string | null>(
|
||||
Object.keys(action.settings.outputSchema).length
|
||||
? JSON.stringify(
|
||||
convertOutputSchemaToJson(
|
||||
action.settings.outputSchema as BaseOutputSchemaV2,
|
||||
),
|
||||
null,
|
||||
2,
|
||||
)
|
||||
isDefined(initialExpectedBody)
|
||||
? JSON.stringify(initialExpectedBody, null, 2)
|
||||
: null,
|
||||
);
|
||||
|
||||
@@ -52,6 +70,7 @@ export const useHttpRequestOutputSchema = ({
|
||||
...action,
|
||||
settings: {
|
||||
...action.settings,
|
||||
expectedOutputSchema: parsingResult.data,
|
||||
outputSchema: getHttpRequestOutputSchema(parsingResult.data),
|
||||
},
|
||||
});
|
||||
|
||||
+21
@@ -9,6 +9,7 @@ import { TabList } from '@/ui/layout/tab-list/components/TabList';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { type WorkflowLogicFunctionAction } from '@/workflow/types/Workflow';
|
||||
import { WorkflowExpectedOutputBodyInput } from '@/workflow/workflow-steps/components/WorkflowExpectedOutputBodyInput';
|
||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||
import { WorkflowStepCmdEnterButton } from '@/workflow/workflow-steps/components/WorkflowStepCmdEnterButton';
|
||||
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
|
||||
@@ -174,6 +175,21 @@ export const WorkflowEditActionLogicFunction = ({
|
||||
updateLogicFunctionInput(updatedTestFunctionInput);
|
||||
};
|
||||
|
||||
const handleExpectedOutputBodyChange = (
|
||||
parsedValue: Record<string, unknown>,
|
||||
) => {
|
||||
if (actionOptions.readonly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateAction({
|
||||
settings: {
|
||||
...action.settings,
|
||||
expectedOutputSchema: parsedValue,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleTestFunction = async () => {
|
||||
if (actionOptions.readonly === true) {
|
||||
return;
|
||||
@@ -264,6 +280,11 @@ export const WorkflowEditActionLogicFunction = ({
|
||||
description={t`You can see the function logic in your application settings.`}
|
||||
/>
|
||||
)}
|
||||
<WorkflowExpectedOutputBodyInput
|
||||
defaultValue={action.settings.expectedOutputSchema}
|
||||
onChange={handleExpectedOutputBodyChange}
|
||||
readonly={actionOptions.readonly}
|
||||
/>
|
||||
</StyledContainer>
|
||||
)}
|
||||
</WorkflowStepBody>
|
||||
|
||||
+1
@@ -160,6 +160,7 @@ export const WorkflowEditTriggerWebhookForm = ({
|
||||
...trigger.settings,
|
||||
httpMethod: 'POST',
|
||||
expectedBody: parsingResult.data,
|
||||
expectedOutputSchema: parsingResult.data,
|
||||
outputSchema,
|
||||
} satisfies WorkflowWebhookTrigger['settings'],
|
||||
},
|
||||
|
||||
+3
@@ -18,6 +18,9 @@ describe('getWebhookTriggerDefaultSettings', () => {
|
||||
expectedBody: {
|
||||
message: 'Workflow was started',
|
||||
},
|
||||
expectedOutputSchema: {
|
||||
message: 'Workflow was started',
|
||||
},
|
||||
outputSchema: {
|
||||
message: {
|
||||
icon: 'IconVariable',
|
||||
|
||||
+3
@@ -27,6 +27,9 @@ export const getWebhookTriggerDefaultSettings = (
|
||||
expectedBody: {
|
||||
message: 'Workflow was started',
|
||||
},
|
||||
expectedOutputSchema: {
|
||||
message: 'Workflow was started',
|
||||
},
|
||||
authentication: null,
|
||||
};
|
||||
}
|
||||
|
||||
+9
-19
@@ -14,6 +14,7 @@ import {
|
||||
computeStepOutputSchema,
|
||||
shouldComputeOutputSchemaOnFrontend,
|
||||
} from '@/workflow/workflow-variables/utils/generate/computeStepOutputSchema';
|
||||
import { resolvePersistedStepOutputSchema } from '@/workflow/workflow-variables/utils/resolvePersistedStepOutputSchema';
|
||||
import { useStore } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -44,29 +45,15 @@ export const useStepsOutputSchema = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Remove this fallback after upgrade command
|
||||
// `upgrade:1-21:migrate-ai-agent-text-to-json-response-format`
|
||||
// has run on all workspaces.
|
||||
const persistedOutputSchema =
|
||||
step.type === 'AI_AGENT' &&
|
||||
(!isDefined(step.settings?.outputSchema) ||
|
||||
Object.keys(step.settings.outputSchema).length === 0)
|
||||
? {
|
||||
response: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'Response',
|
||||
value: null,
|
||||
},
|
||||
}
|
||||
: step.settings?.outputSchema;
|
||||
|
||||
const outputSchema = shouldComputeOnFrontend
|
||||
? computeStepOutputSchema({
|
||||
step,
|
||||
objectMetadataItems,
|
||||
})
|
||||
: persistedOutputSchema;
|
||||
: resolvePersistedStepOutputSchema({
|
||||
stepType: step.type,
|
||||
settings: step.settings,
|
||||
});
|
||||
|
||||
const stepOutputSchema: StepOutputSchemaV2 = {
|
||||
id: step.id,
|
||||
@@ -115,7 +102,10 @@ export const useStepsOutputSchema = () => {
|
||||
step: trigger,
|
||||
objectMetadataItems,
|
||||
})
|
||||
: trigger.settings?.outputSchema;
|
||||
: resolvePersistedStepOutputSchema({
|
||||
stepType: trigger.type,
|
||||
settings: trigger.settings,
|
||||
});
|
||||
|
||||
const triggerOutputSchema: StepOutputSchemaV2 = {
|
||||
id: TRIGGER_STEP_ID,
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { type OutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
|
||||
import { getOutputSchemaFromValue } from 'twenty-shared/logic-function';
|
||||
import { isEmptyObject, isPlainObject } from 'twenty-shared/utils';
|
||||
import { isBaseOutputSchemaV2 } from 'twenty-shared/workflow';
|
||||
|
||||
const AI_AGENT_DEFAULT_OUTPUT_SCHEMA: OutputSchemaV2 = {
|
||||
response: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'Response',
|
||||
value: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const resolvePersistedStepOutputSchema = ({
|
||||
stepType,
|
||||
settings,
|
||||
}: {
|
||||
stepType: string;
|
||||
settings?:
|
||||
| { outputSchema?: unknown; expectedOutputSchema?: unknown }
|
||||
| null
|
||||
| undefined;
|
||||
}): OutputSchemaV2 => {
|
||||
const outputSchema = settings?.outputSchema;
|
||||
|
||||
if (isBaseOutputSchemaV2(outputSchema)) {
|
||||
return outputSchema;
|
||||
}
|
||||
|
||||
const expectedOutputSchema = settings?.expectedOutputSchema;
|
||||
|
||||
if (
|
||||
isPlainObject(expectedOutputSchema) &&
|
||||
!isEmptyObject(expectedOutputSchema)
|
||||
) {
|
||||
return getOutputSchemaFromValue(expectedOutputSchema);
|
||||
}
|
||||
|
||||
if (stepType === 'AI_AGENT') {
|
||||
return AI_AGENT_DEFAULT_OUTPUT_SCHEMA;
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
+25
-3
@@ -5,17 +5,18 @@ import { In } from 'typeorm';
|
||||
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { CommandMenuItemService } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.service';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { LogicFunctionFromSourceService } from 'src/engine/metadata-modules/logic-function/services/logic-function-from-source.service';
|
||||
import {
|
||||
LogicFunctionException,
|
||||
LogicFunctionExceptionCode,
|
||||
} from 'src/engine/metadata-modules/logic-function/logic-function.exception';
|
||||
import { LogicFunctionFromSourceService } from 'src/engine/metadata-modules/logic-function/services/logic-function-from-source.service';
|
||||
import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
@@ -33,12 +34,12 @@ import {
|
||||
WorkflowStatus,
|
||||
type WorkflowWorkspaceEntity,
|
||||
} from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import {
|
||||
WorkflowTriggerException,
|
||||
WorkflowTriggerExceptionCode,
|
||||
} from 'src/modules/workflow/workflow-trigger/exceptions/workflow-trigger.exception';
|
||||
import { getWorkflowCommandMenuItemLabel } from 'src/modules/workflow/workflow-trigger/utils/get-workflow-command-menu-item-label.util';
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
|
||||
export type ObjectMetadataInfo = {
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
@@ -202,6 +203,27 @@ export class WorkflowCommonWorkspaceService {
|
||||
};
|
||||
}
|
||||
|
||||
async getLogicFunctionById({
|
||||
logicFunctionId,
|
||||
workspaceId,
|
||||
}: {
|
||||
logicFunctionId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<FlatLogicFunction | undefined> {
|
||||
const { flatLogicFunctionMaps } =
|
||||
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatLogicFunctionMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
return findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: logicFunctionId,
|
||||
flatEntityMaps: flatLogicFunctionMaps,
|
||||
});
|
||||
}
|
||||
|
||||
async getObjectMetadataInfo(
|
||||
objectNameSingular: string,
|
||||
workspaceId: string,
|
||||
|
||||
+121
-4
@@ -1,6 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { isString } from '@sniptt/guards';
|
||||
import {
|
||||
getOutputSchemaFromValue,
|
||||
inputSchemaToOutputSchema,
|
||||
} from 'twenty-shared/logic-function';
|
||||
import { isDefined, isValidVariable } from 'twenty-shared/utils';
|
||||
import {
|
||||
BaseOutputSchemaV2,
|
||||
@@ -8,6 +12,7 @@ import {
|
||||
BulkRecordsAvailability,
|
||||
extractRawVariableNamePart,
|
||||
GlobalAvailability,
|
||||
isBaseOutputSchemaV2,
|
||||
navigateOutputSchemaProperty,
|
||||
SingleRecordAvailability,
|
||||
TRIGGER_STEP_ID,
|
||||
@@ -20,6 +25,7 @@ import {
|
||||
import { type DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
|
||||
import { checkStringIsDatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/utils/check-string-is-database-event-action';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { generateFakeValue } from 'src/engine/utils/generate-fake-value';
|
||||
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
|
||||
import { DEFAULT_ITERATOR_CURRENT_ITEM } from 'src/modules/workflow/workflow-builder/workflow-schema/constants/default-iterator-current-item.const';
|
||||
@@ -43,6 +49,8 @@ import {
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowSchemaWorkspaceService {
|
||||
private readonly logger = new Logger(WorkflowSchemaWorkspaceService.name);
|
||||
|
||||
constructor(
|
||||
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
|
||||
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
@@ -78,7 +86,6 @@ export class WorkflowSchemaWorkspaceService {
|
||||
|
||||
return {};
|
||||
}
|
||||
case WorkflowTriggerType.WEBHOOK:
|
||||
case WorkflowTriggerType.CRON: {
|
||||
return {};
|
||||
}
|
||||
@@ -136,7 +143,23 @@ export class WorkflowSchemaWorkspaceService {
|
||||
},
|
||||
};
|
||||
}
|
||||
case WorkflowActionType.CODE: // StepOutput schema is computed on logicFunction draft execution
|
||||
case WorkflowTriggerType.WEBHOOK:
|
||||
case WorkflowActionType.CODE:
|
||||
case WorkflowActionType.HTTP_REQUEST: {
|
||||
const expectedOutputSchema =
|
||||
'expectedOutputSchema' in step.settings
|
||||
? step.settings.expectedOutputSchema
|
||||
: undefined;
|
||||
|
||||
return this.computeOutputSchemaFromExpectedSample(expectedOutputSchema);
|
||||
}
|
||||
case WorkflowActionType.LOGIC_FUNCTION: {
|
||||
return this.computeLogicFunctionOutputSchema({
|
||||
logicFunctionId: step.settings.input.logicFunctionId,
|
||||
expectedOutputSchema: step.settings.expectedOutputSchema,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
@@ -172,6 +195,100 @@ export class WorkflowSchemaWorkspaceService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private computeOutputSchemaFromExpectedSample(
|
||||
expectedOutputSchema: object | undefined,
|
||||
): OutputSchema {
|
||||
if (
|
||||
isDefined(expectedOutputSchema) &&
|
||||
Object.keys(expectedOutputSchema).length > 0
|
||||
) {
|
||||
return getOutputSchemaFromValue(expectedOutputSchema);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private getOutputSchemaWithExpectedFallback(settings: {
|
||||
outputSchema?: OutputSchema;
|
||||
expectedOutputSchema?: object;
|
||||
}): BaseOutputSchemaV2 {
|
||||
const outputSchema = settings.outputSchema;
|
||||
|
||||
if (isBaseOutputSchemaV2(outputSchema)) {
|
||||
return outputSchema;
|
||||
}
|
||||
|
||||
const expectedOutputSchema = this.computeOutputSchemaFromExpectedSample(
|
||||
settings.expectedOutputSchema,
|
||||
);
|
||||
|
||||
return isBaseOutputSchemaV2(expectedOutputSchema)
|
||||
? expectedOutputSchema
|
||||
: {};
|
||||
}
|
||||
|
||||
private async computeLogicFunctionOutputSchema({
|
||||
logicFunctionId,
|
||||
expectedOutputSchema,
|
||||
workspaceId,
|
||||
}: {
|
||||
logicFunctionId: string;
|
||||
expectedOutputSchema: object | undefined;
|
||||
workspaceId: string;
|
||||
}): Promise<OutputSchema> {
|
||||
const declaredOutputSchema =
|
||||
await this.getLogicFunctionDeclaredOutputSchema({
|
||||
logicFunctionId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (isDefined(declaredOutputSchema)) {
|
||||
return declaredOutputSchema;
|
||||
}
|
||||
|
||||
return this.computeOutputSchemaFromExpectedSample(expectedOutputSchema);
|
||||
}
|
||||
|
||||
private async getLogicFunctionDeclaredOutputSchema({
|
||||
logicFunctionId,
|
||||
workspaceId,
|
||||
}: {
|
||||
logicFunctionId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<BaseOutputSchemaV2 | undefined> {
|
||||
if (!isDefined(logicFunctionId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { flatLogicFunctionMaps } =
|
||||
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatLogicFunctionMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
const flatLogicFunction = findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: logicFunctionId,
|
||||
flatEntityMaps: flatLogicFunctionMaps,
|
||||
});
|
||||
|
||||
const declaredInputSchema =
|
||||
flatLogicFunction?.workflowActionTriggerSettings?.outputSchema;
|
||||
|
||||
if (!isDefined(declaredInputSchema)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const declaredOutputSchema = inputSchemaToOutputSchema(declaredInputSchema);
|
||||
|
||||
if (Object.keys(declaredOutputSchema).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return declaredOutputSchema;
|
||||
}
|
||||
|
||||
private async computeDatabaseEventTriggerOutputSchema({
|
||||
eventName,
|
||||
workspaceId,
|
||||
@@ -481,7 +598,7 @@ export class WorkflowSchemaWorkspaceService {
|
||||
case WorkflowActionType.LOGIC_FUNCTION: {
|
||||
const propertyPath = extractPropertyPathFromVariable(items);
|
||||
const schemaNode = navigateOutputSchemaProperty({
|
||||
schema: step.settings.outputSchema as BaseOutputSchemaV2,
|
||||
schema: this.getOutputSchemaWithExpectedFallback(step.settings),
|
||||
propertyPath,
|
||||
});
|
||||
|
||||
|
||||
+415
-5
@@ -1,11 +1,20 @@
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import { type WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
|
||||
import { type WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
|
||||
import { type OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
|
||||
import { WorkflowValidationWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service';
|
||||
import {
|
||||
type WorkflowAiAgentAction,
|
||||
type WorkflowCodeAction,
|
||||
type WorkflowFindRecordsAction,
|
||||
type WorkflowHttpRequestAction,
|
||||
type WorkflowIteratorAction,
|
||||
type WorkflowLogicFunctionAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { WorkflowValidationWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service';
|
||||
import {
|
||||
type WorkflowTrigger,
|
||||
WorkflowTriggerType,
|
||||
} from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
|
||||
const WORKSPACE_ID = 'workspace-id';
|
||||
|
||||
@@ -14,10 +23,19 @@ const ERROR_HANDLING_OPTIONS = {
|
||||
continueOnFailure: { value: false },
|
||||
};
|
||||
|
||||
const buildService = (objectIdByNameSingular: Record<string, string> = {}) => {
|
||||
const buildService = ({
|
||||
objectIdByNameSingular = {},
|
||||
logicFunction = null,
|
||||
}: {
|
||||
objectIdByNameSingular?: Record<string, string>;
|
||||
logicFunction?: object | null;
|
||||
} = {}) => {
|
||||
const workflowCommonWorkspaceService = {
|
||||
getWorkflowVersionOrFail: jest.fn(),
|
||||
getFlatEntityMaps: jest.fn().mockResolvedValue({ objectIdByNameSingular }),
|
||||
getLogicFunctionById: jest
|
||||
.fn()
|
||||
.mockResolvedValue(logicFunction ?? undefined),
|
||||
} as unknown as jest.Mocked<WorkflowCommonWorkspaceService>;
|
||||
|
||||
const workflowSchemaWorkspaceService = {
|
||||
@@ -47,6 +65,36 @@ const buildAiAgentStep = (input: {
|
||||
},
|
||||
});
|
||||
|
||||
const buildHttpRequestStep = (
|
||||
outputSchema: OutputSchema = {},
|
||||
): WorkflowHttpRequestAction => ({
|
||||
id: 'http-request-step',
|
||||
name: 'HTTP Request',
|
||||
type: WorkflowActionType.HTTP_REQUEST,
|
||||
valid: true,
|
||||
settings: {
|
||||
input: {
|
||||
url: 'https://example.com',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
body: {},
|
||||
},
|
||||
outputSchema,
|
||||
errorHandlingOptions: ERROR_HANDLING_OPTIONS,
|
||||
},
|
||||
});
|
||||
|
||||
const buildWebhookTrigger = (outputSchema: object = {}): WorkflowTrigger =>
|
||||
({
|
||||
type: WorkflowTriggerType.WEBHOOK,
|
||||
name: 'Webhook',
|
||||
settings: {
|
||||
outputSchema,
|
||||
httpMethod: 'GET',
|
||||
authentication: null,
|
||||
},
|
||||
}) as unknown as WorkflowTrigger;
|
||||
|
||||
const buildFindRecordsStep = (
|
||||
objectName: string,
|
||||
): WorkflowFindRecordsAction => ({
|
||||
@@ -61,6 +109,107 @@ const buildFindRecordsStep = (
|
||||
},
|
||||
});
|
||||
|
||||
const buildCodeStep = (expectedOutputSchema?: object): WorkflowCodeAction => ({
|
||||
id: 'code-step',
|
||||
name: 'Code',
|
||||
type: WorkflowActionType.CODE,
|
||||
valid: true,
|
||||
settings: {
|
||||
input: {
|
||||
logicFunctionId: 'logic-function-id',
|
||||
logicFunctionInput: {},
|
||||
},
|
||||
outputSchema: {},
|
||||
...(expectedOutputSchema ? { expectedOutputSchema } : {}),
|
||||
errorHandlingOptions: ERROR_HANDLING_OPTIONS,
|
||||
},
|
||||
});
|
||||
|
||||
const FIND_RECORDS_OUTPUT_SCHEMA = {
|
||||
first: {
|
||||
isLeaf: false,
|
||||
label: 'First',
|
||||
value: {
|
||||
_outputSchemaType: 'RECORD',
|
||||
object: { objectMetadataId: 'company-metadata-id', label: 'Company' },
|
||||
fields: {
|
||||
name: {
|
||||
isLeaf: true,
|
||||
type: 'TEXT',
|
||||
label: 'Company Name',
|
||||
value: 'Acme',
|
||||
fieldMetadataId: 'company-name-id',
|
||||
isCompositeSubField: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
all: {
|
||||
isLeaf: true,
|
||||
label: 'All',
|
||||
value: 'Returns an array of records',
|
||||
type: 'array',
|
||||
},
|
||||
totalCount: {
|
||||
isLeaf: true,
|
||||
label: 'Total Count',
|
||||
value: 42,
|
||||
type: 'number',
|
||||
},
|
||||
};
|
||||
|
||||
const buildFindRecordsStepWithOutputSchema = (
|
||||
outputSchema: object,
|
||||
): WorkflowFindRecordsAction => ({
|
||||
id: 'find-records-step',
|
||||
name: 'Find Records',
|
||||
type: WorkflowActionType.FIND_RECORDS,
|
||||
valid: true,
|
||||
settings: {
|
||||
input: { objectName: 'company' },
|
||||
outputSchema: outputSchema as OutputSchema,
|
||||
errorHandlingOptions: ERROR_HANDLING_OPTIONS,
|
||||
},
|
||||
});
|
||||
|
||||
const buildIteratorStep = (items: string | unknown[]): WorkflowIteratorAction =>
|
||||
({
|
||||
id: 'iterator-step',
|
||||
name: 'Iterator',
|
||||
type: WorkflowActionType.ITERATOR,
|
||||
valid: true,
|
||||
settings: {
|
||||
input: { items, initialLoopStepIds: ['body-step'] },
|
||||
errorHandlingOptions: ERROR_HANDLING_OPTIONS,
|
||||
},
|
||||
}) as unknown as WorkflowIteratorAction;
|
||||
|
||||
const buildLogicFunctionDefinition = (
|
||||
properties: Record<string, { type: string; label: string }>,
|
||||
): object => ({
|
||||
workflowActionTriggerSettings: {
|
||||
outputSchema: [{ type: 'object', label: 'Output', properties }],
|
||||
},
|
||||
});
|
||||
|
||||
const buildLogicFunctionStep = (
|
||||
expectedOutputSchema?: object,
|
||||
): WorkflowLogicFunctionAction => ({
|
||||
id: 'logic-function-step',
|
||||
name: 'Logic Function',
|
||||
type: WorkflowActionType.LOGIC_FUNCTION,
|
||||
valid: true,
|
||||
settings: {
|
||||
input: {
|
||||
logicFunctionId: 'logic-function-id',
|
||||
logicFunctionInput: {},
|
||||
},
|
||||
outputSchema: {},
|
||||
...(expectedOutputSchema ? { expectedOutputSchema } : {}),
|
||||
errorHandlingOptions: ERROR_HANDLING_OPTIONS,
|
||||
},
|
||||
});
|
||||
|
||||
describe('WorkflowValidationWorkspaceService', () => {
|
||||
it('should flag an AI Agent step that has no agent selected', async () => {
|
||||
const service = buildService();
|
||||
@@ -91,7 +240,7 @@ describe('WorkflowValidationWorkspaceService', () => {
|
||||
});
|
||||
|
||||
it('should flag a record step targeting an object that does not exist in the workspace', async () => {
|
||||
const service = buildService({});
|
||||
const service = buildService({ objectIdByNameSingular: {} });
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
@@ -107,7 +256,9 @@ describe('WorkflowValidationWorkspaceService', () => {
|
||||
});
|
||||
|
||||
it('should not flag a record step targeting an existing object', async () => {
|
||||
const service = buildService({ person: 'object-id-1' });
|
||||
const service = buildService({
|
||||
objectIdByNameSingular: { person: 'object-id-1' },
|
||||
});
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
@@ -121,4 +272,263 @@ describe('WorkflowValidationWorkspaceService', () => {
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should error about a code step that has no output schema', async () => {
|
||||
const service = buildService();
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
trigger: null,
|
||||
steps: [buildCodeStep()],
|
||||
});
|
||||
|
||||
expect(result.errors.map((issue) => issue.code)).toContain(
|
||||
'CODE_STEP_MISSING_OUTPUT_SCHEMA',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not warn about a code step that declares an expected output schema', async () => {
|
||||
const service = buildService();
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
trigger: null,
|
||||
steps: [buildCodeStep({ greeting: 'hello' })],
|
||||
});
|
||||
|
||||
expect(result.warnings.map((issue) => issue.code)).not.toContain(
|
||||
'CODE_STEP_MISSING_OUTPUT_SCHEMA',
|
||||
);
|
||||
});
|
||||
|
||||
it('should error about a logic function step with no output schema from any source', async () => {
|
||||
const service = buildService({ logicFunction: null });
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
trigger: null,
|
||||
steps: [buildLogicFunctionStep()],
|
||||
});
|
||||
|
||||
expect(result.errors.map((issue) => issue.code)).toContain(
|
||||
'CODE_STEP_MISSING_OUTPUT_SCHEMA',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not warn about a logic function step whose definition declares an output schema', async () => {
|
||||
const service = buildService({
|
||||
logicFunction: buildLogicFunctionDefinition({
|
||||
result: { type: 'string', label: 'result' },
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
trigger: null,
|
||||
steps: [buildLogicFunctionStep()],
|
||||
});
|
||||
|
||||
expect(result.warnings.map((issue) => issue.code)).not.toContain(
|
||||
'CODE_STEP_MISSING_OUTPUT_SCHEMA',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not warn about a logic function step that declares an expected output schema on the step', async () => {
|
||||
const service = buildService({ logicFunction: null });
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
trigger: null,
|
||||
steps: [buildLogicFunctionStep({ greeting: 'hello' })],
|
||||
});
|
||||
|
||||
expect(result.warnings.map((issue) => issue.code)).not.toContain(
|
||||
'CODE_STEP_MISSING_OUTPUT_SCHEMA',
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn when the logic function step expected output schema does not match the declared output schema', async () => {
|
||||
const service = buildService({
|
||||
logicFunction: buildLogicFunctionDefinition({
|
||||
result: { type: 'string', label: 'result' },
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
trigger: null,
|
||||
steps: [buildLogicFunctionStep({ result: 123 })],
|
||||
});
|
||||
|
||||
expect(result.warnings.map((issue) => issue.code)).toContain(
|
||||
'LOGIC_FUNCTION_OUTPUT_SCHEMA_MISMATCH',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not warn when the logic function step expected output schema matches the declared output schema', async () => {
|
||||
const service = buildService({
|
||||
logicFunction: buildLogicFunctionDefinition({
|
||||
result: { type: 'string', label: 'result' },
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
trigger: null,
|
||||
steps: [buildLogicFunctionStep({ result: 'hello' })],
|
||||
});
|
||||
|
||||
expect(result.warnings.map((issue) => issue.code)).not.toContain(
|
||||
'LOGIC_FUNCTION_OUTPUT_SCHEMA_MISMATCH',
|
||||
);
|
||||
});
|
||||
|
||||
it('should error about an HTTP request step that has no output schema', async () => {
|
||||
const service = buildService();
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
trigger: null,
|
||||
steps: [buildHttpRequestStep({})],
|
||||
});
|
||||
|
||||
expect(result.errors.map((issue) => issue.code)).toContain(
|
||||
'CODE_STEP_MISSING_OUTPUT_SCHEMA',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not error about an HTTP request step that declares an output schema', async () => {
|
||||
const service = buildService();
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
trigger: null,
|
||||
steps: [
|
||||
buildHttpRequestStep({
|
||||
status: { isLeaf: true, type: 'number', label: 'status', value: 200 },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.errors.map((issue) => issue.code)).not.toContain(
|
||||
'CODE_STEP_MISSING_OUTPUT_SCHEMA',
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn when a variable-consuming step references no variable', async () => {
|
||||
const service = buildService();
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
trigger: null,
|
||||
steps: [buildHttpRequestStep({})],
|
||||
});
|
||||
|
||||
expect(result.warnings.map((issue) => issue.code)).toContain(
|
||||
'STEP_HAS_NO_VARIABLE_REFERENCE',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not warn about a missing variable reference when the step references a variable', async () => {
|
||||
const service = buildService();
|
||||
|
||||
const httpRequestStepWithVariable = buildHttpRequestStep({});
|
||||
|
||||
httpRequestStepWithVariable.settings.input.body = {
|
||||
name: '{{trigger.body.name}}',
|
||||
};
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
trigger: null,
|
||||
steps: [httpRequestStepWithVariable],
|
||||
});
|
||||
|
||||
expect(result.warnings.map((issue) => issue.code)).not.toContain(
|
||||
'STEP_HAS_NO_VARIABLE_REFERENCE',
|
||||
);
|
||||
});
|
||||
|
||||
it('should error about a webhook trigger that has no output schema', async () => {
|
||||
const service = buildService();
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
trigger: buildWebhookTrigger({}),
|
||||
steps: [buildFindRecordsStep('person')],
|
||||
});
|
||||
|
||||
expect(result.errors.map((issue) => issue.code)).toContain(
|
||||
'CODE_STEP_MISSING_OUTPUT_SCHEMA',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not error about a webhook trigger that declares an output schema', async () => {
|
||||
const service = buildService({
|
||||
objectIdByNameSingular: { person: 'object-id-1' },
|
||||
});
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
trigger: buildWebhookTrigger({
|
||||
body: { isLeaf: false, type: 'object', label: 'body', value: {} },
|
||||
}),
|
||||
steps: [buildFindRecordsStep('person')],
|
||||
});
|
||||
|
||||
expect(result.errors.map((issue) => issue.code)).not.toContain(
|
||||
'CODE_STEP_MISSING_OUTPUT_SCHEMA',
|
||||
);
|
||||
});
|
||||
|
||||
it('should flag an iterator whose items reference a non-array path and suggest array paths', async () => {
|
||||
const service = buildService();
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
trigger: null,
|
||||
steps: [
|
||||
buildFindRecordsStepWithOutputSchema(FIND_RECORDS_OUTPUT_SCHEMA),
|
||||
buildIteratorStep('{{find-records-step.first}}'),
|
||||
],
|
||||
});
|
||||
|
||||
const iteratorIssue = result.errors.find(
|
||||
(issue) => issue.code === 'ITERATOR_ITEMS_NOT_ARRAY',
|
||||
);
|
||||
|
||||
expect(iteratorIssue).toBeDefined();
|
||||
expect(iteratorIssue?.suggestions).toContain('find-records-step.all');
|
||||
});
|
||||
|
||||
it('should not flag an iterator whose items reference an array path', async () => {
|
||||
const service = buildService();
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
trigger: null,
|
||||
steps: [
|
||||
buildFindRecordsStepWithOutputSchema(FIND_RECORDS_OUTPUT_SCHEMA),
|
||||
buildIteratorStep('{{find-records-step.all}}'),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.errors.map((issue) => issue.code)).not.toContain(
|
||||
'ITERATOR_ITEMS_NOT_ARRAY',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not flag an iterator that iterates over an inline array', async () => {
|
||||
const service = buildService();
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
trigger: null,
|
||||
steps: [buildIteratorStep(['one', 'two'])],
|
||||
});
|
||||
|
||||
expect(result.errors.map((issue) => issue.code)).not.toContain(
|
||||
'ITERATOR_ITEMS_NOT_ARRAY',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+362
-7
@@ -1,12 +1,28 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString, isObject, isString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
isNonEmptyArray,
|
||||
isNonEmptyString,
|
||||
isObject,
|
||||
isString,
|
||||
} from '@sniptt/guards';
|
||||
import {
|
||||
getOutputSchemaFromValue,
|
||||
getOutputSchemaMismatchIssues,
|
||||
inputSchemaToOutputSchema,
|
||||
} from 'twenty-shared/logic-function';
|
||||
import { isDefined, isValidVariable } from 'twenty-shared/utils';
|
||||
import {
|
||||
type BaseOutputSchemaV2,
|
||||
collectOutputSchemaVariablePaths,
|
||||
extractVariablesFromInput,
|
||||
parseVariablePath,
|
||||
resolveVariablePathInOutputSchema,
|
||||
TRIGGER_STEP_ID,
|
||||
validateWorkflowStructure,
|
||||
WorkflowActionType,
|
||||
type WorkflowValidationIssue,
|
||||
type WorkflowValidationResult,
|
||||
WorkflowActionType,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
|
||||
@@ -14,8 +30,13 @@ import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-bu
|
||||
import {
|
||||
type WorkflowAction,
|
||||
type WorkflowAiAgentAction,
|
||||
type WorkflowIteratorAction,
|
||||
type WorkflowLogicFunctionAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
|
||||
import {
|
||||
type WorkflowTrigger,
|
||||
WorkflowTriggerType,
|
||||
} from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
|
||||
|
||||
const RECORD_CRUD_ACTION_TYPES = new Set<WorkflowActionType>([
|
||||
WorkflowActionType.CREATE_RECORD,
|
||||
@@ -25,6 +46,14 @@ const RECORD_CRUD_ACTION_TYPES = new Set<WorkflowActionType>([
|
||||
WorkflowActionType.FIND_RECORDS,
|
||||
]);
|
||||
|
||||
const VARIABLE_CONSUMING_ACTION_TYPES = new Set<WorkflowActionType>([
|
||||
WorkflowActionType.HTTP_REQUEST,
|
||||
WorkflowActionType.CODE,
|
||||
WorkflowActionType.LOGIC_FUNCTION,
|
||||
WorkflowActionType.SEND_EMAIL,
|
||||
...RECORD_CRUD_ACTION_TYPES,
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowValidationWorkspaceService {
|
||||
constructor(
|
||||
@@ -77,8 +106,12 @@ export class WorkflowValidationWorkspaceService {
|
||||
steps: enrichedSteps,
|
||||
});
|
||||
|
||||
const semanticIssues = this.validateStepTypeRequirements({
|
||||
const triggerIssues = this.validateTriggerTypeRequirements(enrichedTrigger);
|
||||
|
||||
const semanticIssues = await this.validateStepTypeRequirements({
|
||||
workspaceId,
|
||||
steps: enrichedSteps ?? [],
|
||||
trigger: enrichedTrigger,
|
||||
});
|
||||
|
||||
const metadataIssues = await this.validateWorkspaceMetadata({
|
||||
@@ -86,9 +119,15 @@ export class WorkflowValidationWorkspaceService {
|
||||
steps: enrichedSteps ?? [],
|
||||
});
|
||||
|
||||
const variableReferenceIssues = this.validateStepsHaveVariableReferences(
|
||||
enrichedSteps ?? [],
|
||||
);
|
||||
|
||||
return mergeValidationResults(staticResult, [
|
||||
...triggerIssues,
|
||||
...semanticIssues,
|
||||
...metadataIssues,
|
||||
...variableReferenceIssues,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -167,11 +206,15 @@ export class WorkflowValidationWorkspaceService {
|
||||
}
|
||||
}
|
||||
|
||||
private validateStepTypeRequirements({
|
||||
private async validateStepTypeRequirements({
|
||||
workspaceId,
|
||||
steps,
|
||||
trigger,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
steps: WorkflowAction[];
|
||||
}): WorkflowValidationIssue[] {
|
||||
trigger: WorkflowTrigger | null;
|
||||
}): Promise<WorkflowValidationIssue[]> {
|
||||
const issues: WorkflowValidationIssue[] = [];
|
||||
|
||||
for (const step of steps) {
|
||||
@@ -179,12 +222,324 @@ export class WorkflowValidationWorkspaceService {
|
||||
case WorkflowActionType.AI_AGENT:
|
||||
issues.push(...this.validateAiAgentStep(step));
|
||||
break;
|
||||
case WorkflowActionType.CODE:
|
||||
case WorkflowActionType.HTTP_REQUEST:
|
||||
issues.push(...this.validateRuntimeOutputStep(step));
|
||||
break;
|
||||
case WorkflowActionType.LOGIC_FUNCTION:
|
||||
issues.push(
|
||||
...(await this.validateLogicFunctionStep({ step, workspaceId })),
|
||||
);
|
||||
break;
|
||||
case WorkflowActionType.ITERATOR:
|
||||
issues.push(...this.validateIteratorStep({ step, steps, trigger }));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private validateIteratorStep({
|
||||
step,
|
||||
steps,
|
||||
trigger,
|
||||
}: {
|
||||
step: WorkflowIteratorAction;
|
||||
steps: WorkflowAction[];
|
||||
trigger: WorkflowTrigger | null;
|
||||
}): WorkflowValidationIssue[] {
|
||||
const items = step.settings?.input?.items;
|
||||
|
||||
if (!isString(items) || !isValidVariable(items)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [variable] = extractVariablesFromInput(items);
|
||||
|
||||
if (!isDefined(variable)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [referencedStepId, ...propertyPath] = parseVariablePath(variable);
|
||||
|
||||
if (!isDefined(referencedStepId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const outputSchema =
|
||||
referencedStepId === TRIGGER_STEP_ID
|
||||
? trigger?.settings?.outputSchema
|
||||
: steps.find((currentStep) => currentStep.id === referencedStepId)
|
||||
?.settings?.outputSchema;
|
||||
|
||||
if (!isDefined(outputSchema) || !isObject(outputSchema)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resolved = resolveVariablePathInOutputSchema({
|
||||
schema: outputSchema,
|
||||
propertyPath,
|
||||
});
|
||||
|
||||
if (resolved.found && resolved.type === 'array') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const arrayPathSuggestions = collectOutputSchemaVariablePaths(outputSchema)
|
||||
.filter(
|
||||
(path) =>
|
||||
resolveVariablePathInOutputSchema({
|
||||
schema: outputSchema,
|
||||
propertyPath: path.split('.'),
|
||||
}).type === 'array',
|
||||
)
|
||||
.map((path) => `${referencedStepId}.${path}`);
|
||||
|
||||
const hint = isNonEmptyArray(arrayPathSuggestions)
|
||||
? `Did you mean "{{${arrayPathSuggestions[0]}}}"?${
|
||||
arrayPathSuggestions.length > 1
|
||||
? ` Other options: ${arrayPathSuggestions
|
||||
.slice(1)
|
||||
.map((suggestion) => `{{${suggestion}}}`)
|
||||
.join(', ')}.`
|
||||
: ''
|
||||
}`
|
||||
: undefined;
|
||||
|
||||
return [
|
||||
{
|
||||
severity: 'error',
|
||||
code: 'ITERATOR_ITEMS_NOT_ARRAY',
|
||||
message: `Iterator step "${step.name ?? step.id}" must iterate over an array, but "{{${variable}}}" is not an array.`,
|
||||
stepId: step.id,
|
||||
path: variable,
|
||||
...(isDefined(hint) ? { hint } : {}),
|
||||
...(isNonEmptyArray(arrayPathSuggestions)
|
||||
? { suggestions: arrayPathSuggestions }
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private validateStepsHaveVariableReferences(
|
||||
steps: WorkflowAction[],
|
||||
): WorkflowValidationIssue[] {
|
||||
const issues: WorkflowValidationIssue[] = [];
|
||||
|
||||
for (const step of steps) {
|
||||
if (!VARIABLE_CONSUMING_ACTION_TYPES.has(step.type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const variables = extractVariablesFromInput(step.settings?.input);
|
||||
|
||||
if (variables.length > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
code: 'STEP_HAS_NO_VARIABLE_REFERENCE',
|
||||
message: `Step "${step.name ?? step.id}" does not reference any variable from previous steps.`,
|
||||
stepId: step.id,
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private validateTriggerTypeRequirements(
|
||||
trigger: WorkflowTrigger | null,
|
||||
): WorkflowValidationIssue[] {
|
||||
if (trigger?.type !== WorkflowTriggerType.WEBHOOK) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (this.hasOutputSchema(trigger.settings)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
this.buildMissingOutputSchemaIssue({
|
||||
id: TRIGGER_STEP_ID,
|
||||
name: trigger.name,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
private validateRuntimeOutputStep(
|
||||
step: WorkflowAction,
|
||||
): WorkflowValidationIssue[] {
|
||||
if (this.hasStepLevelOutputSchema(step)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
this.buildMissingOutputSchemaIssue({ id: step.id, name: step.name }),
|
||||
];
|
||||
}
|
||||
|
||||
// A CODE/LOGIC_FUNCTION step exposes an output schema either through a
|
||||
// user-declared sample (expectedOutputSchema) or through a schema computed
|
||||
// after a draft/test run (outputSchema). The LINK placeholder is not usable.
|
||||
private hasStepLevelOutputSchema(step: WorkflowAction): boolean {
|
||||
return this.hasOutputSchema(step.settings);
|
||||
}
|
||||
|
||||
private hasOutputSchema(
|
||||
settings:
|
||||
| { outputSchema?: unknown; expectedOutputSchema?: unknown }
|
||||
| null
|
||||
| undefined,
|
||||
): boolean {
|
||||
const expectedOutputSchema = settings?.expectedOutputSchema;
|
||||
|
||||
if (
|
||||
isObject(expectedOutputSchema) &&
|
||||
Object.keys(expectedOutputSchema).length > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const outputSchema = settings?.outputSchema;
|
||||
|
||||
return (
|
||||
isObject(outputSchema) &&
|
||||
Object.keys(outputSchema).length > 0 &&
|
||||
!(
|
||||
'_outputSchemaType' in outputSchema &&
|
||||
outputSchema._outputSchemaType === 'LINK'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private buildMissingOutputSchemaIssue({
|
||||
id,
|
||||
name,
|
||||
}: {
|
||||
id: string;
|
||||
name?: string;
|
||||
}): WorkflowValidationIssue {
|
||||
return {
|
||||
severity: 'error',
|
||||
code: 'CODE_STEP_MISSING_OUTPUT_SCHEMA',
|
||||
message: `Step "${name ?? id}" has no output schema. Declare an expected output schema.`,
|
||||
stepId: id,
|
||||
};
|
||||
}
|
||||
|
||||
private async validateLogicFunctionStep({
|
||||
step,
|
||||
workspaceId,
|
||||
}: {
|
||||
step: WorkflowLogicFunctionAction;
|
||||
workspaceId: string;
|
||||
}): Promise<WorkflowValidationIssue[]> {
|
||||
const issues: WorkflowValidationIssue[] = [];
|
||||
|
||||
const declaredOutputSchema =
|
||||
await this.getLogicFunctionDeclaredOutputSchema({
|
||||
step,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
issues.push(
|
||||
...this.validateLogicFunctionOutputSchemaMismatch({
|
||||
step,
|
||||
declaredOutputSchema,
|
||||
}),
|
||||
);
|
||||
|
||||
if (this.hasStepLevelOutputSchema(step)) {
|
||||
return issues;
|
||||
}
|
||||
|
||||
if (isDefined(declaredOutputSchema)) {
|
||||
return issues;
|
||||
}
|
||||
|
||||
issues.push(
|
||||
this.buildMissingOutputSchemaIssue({ id: step.id, name: step.name }),
|
||||
);
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private validateLogicFunctionOutputSchemaMismatch({
|
||||
step,
|
||||
declaredOutputSchema,
|
||||
}: {
|
||||
step: WorkflowLogicFunctionAction;
|
||||
declaredOutputSchema: BaseOutputSchemaV2 | undefined;
|
||||
}): WorkflowValidationIssue[] {
|
||||
const expectedOutputSchema = step.settings?.expectedOutputSchema;
|
||||
|
||||
if (
|
||||
!isDefined(declaredOutputSchema) ||
|
||||
!isObject(expectedOutputSchema) ||
|
||||
Object.keys(expectedOutputSchema).length === 0
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const mismatchIssues = getOutputSchemaMismatchIssues(
|
||||
declaredOutputSchema,
|
||||
getOutputSchemaFromValue(expectedOutputSchema),
|
||||
);
|
||||
|
||||
return mismatchIssues.map((mismatchIssue) => ({
|
||||
severity: 'warning',
|
||||
code: 'LOGIC_FUNCTION_OUTPUT_SCHEMA_MISMATCH',
|
||||
message: `Step "${step.name ?? step.id}" expected output schema does not match the function's declared output schema: ${mismatchIssue}`,
|
||||
stepId: step.id,
|
||||
}));
|
||||
}
|
||||
|
||||
private async getLogicFunctionDeclaredOutputSchema({
|
||||
step,
|
||||
workspaceId,
|
||||
}: {
|
||||
step: WorkflowAction;
|
||||
workspaceId: string;
|
||||
}): Promise<BaseOutputSchemaV2 | undefined> {
|
||||
const input = step.settings?.input;
|
||||
const logicFunctionId =
|
||||
isObject(input) && 'logicFunctionId' in input
|
||||
? input.logicFunctionId
|
||||
: undefined;
|
||||
|
||||
if (!isNonEmptyString(logicFunctionId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const logicFunction =
|
||||
await this.workflowCommonWorkspaceService.getLogicFunctionById({
|
||||
logicFunctionId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const declaredInputSchema =
|
||||
logicFunction?.workflowActionTriggerSettings?.outputSchema;
|
||||
|
||||
if (!isNonEmptyArray(declaredInputSchema)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const declaredOutputSchema =
|
||||
inputSchemaToOutputSchema(declaredInputSchema);
|
||||
|
||||
if (Object.keys(declaredOutputSchema).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return declaredOutputSchema;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private validateAiAgentStep(
|
||||
step: WorkflowAiAgentAction,
|
||||
): WorkflowValidationIssue[] {
|
||||
|
||||
+3
@@ -205,6 +205,7 @@ export class WorkflowVersionStepOperationsWorkspaceService {
|
||||
},
|
||||
_outputSchemaType: 'LINK',
|
||||
},
|
||||
expectedOutputSchema: {},
|
||||
input: {
|
||||
logicFunctionId: newLogicFunction.id,
|
||||
logicFunctionInput: isDefined(
|
||||
@@ -272,6 +273,7 @@ export class WorkflowVersionStepOperationsWorkspaceService {
|
||||
settings: {
|
||||
...BASE_STEP_DEFINITION,
|
||||
outputSchema: initialOutputSchema,
|
||||
expectedOutputSchema: {},
|
||||
input: {
|
||||
logicFunctionId,
|
||||
logicFunctionInput: isDefined(
|
||||
@@ -477,6 +479,7 @@ export class WorkflowVersionStepOperationsWorkspaceService {
|
||||
type: WorkflowActionType.HTTP_REQUEST,
|
||||
settings: {
|
||||
...BASE_STEP_DEFINITION,
|
||||
expectedOutputSchema: {},
|
||||
input: {
|
||||
url: '',
|
||||
method: 'GET',
|
||||
|
||||
+8
-4
@@ -1,6 +1,10 @@
|
||||
import { type WorkflowCodeActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-input.type';
|
||||
import { type BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
|
||||
import {
|
||||
type BaseWorkflowActionSettings,
|
||||
type WithExpectedOutputSchema,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
|
||||
|
||||
export type WorkflowCodeActionSettings = BaseWorkflowActionSettings & {
|
||||
input: WorkflowCodeActionInput;
|
||||
};
|
||||
export type WorkflowCodeActionSettings = BaseWorkflowActionSettings &
|
||||
WithExpectedOutputSchema & {
|
||||
input: WorkflowCodeActionInput;
|
||||
};
|
||||
|
||||
+8
-4
@@ -1,7 +1,11 @@
|
||||
import { type BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
|
||||
import {
|
||||
type BaseWorkflowActionSettings,
|
||||
type WithExpectedOutputSchema,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
|
||||
|
||||
import { type WorkflowHttpRequestActionInput } from './workflow-http-request-action-input.type';
|
||||
|
||||
export type WorkflowHttpRequestActionSettings = BaseWorkflowActionSettings & {
|
||||
input: WorkflowHttpRequestActionInput;
|
||||
};
|
||||
export type WorkflowHttpRequestActionSettings = BaseWorkflowActionSettings &
|
||||
WithExpectedOutputSchema & {
|
||||
input: WorkflowHttpRequestActionInput;
|
||||
};
|
||||
|
||||
+8
-4
@@ -1,6 +1,10 @@
|
||||
import { type WorkflowLogicFunctionActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/logic-function/types/workflow-logic-function-action-input.type';
|
||||
import { type BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
|
||||
import {
|
||||
type BaseWorkflowActionSettings,
|
||||
type WithExpectedOutputSchema,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
|
||||
|
||||
export type WorkflowLogicFunctionActionSettings = BaseWorkflowActionSettings & {
|
||||
input: WorkflowLogicFunctionActionInput;
|
||||
};
|
||||
export type WorkflowLogicFunctionActionSettings = BaseWorkflowActionSettings &
|
||||
WithExpectedOutputSchema & {
|
||||
input: WorkflowLogicFunctionActionInput;
|
||||
};
|
||||
|
||||
+4
@@ -29,6 +29,10 @@ export type BaseWorkflowActionSettings = {
|
||||
};
|
||||
};
|
||||
|
||||
export type WithExpectedOutputSchema = {
|
||||
expectedOutputSchema?: object;
|
||||
};
|
||||
|
||||
export type WorkflowActionSettings =
|
||||
| WorkflowLogicFunctionActionSettings
|
||||
| WorkflowSendEmailActionSettings
|
||||
|
||||
+1
@@ -83,6 +83,7 @@ export type WorkflowWebhookTrigger = BaseTrigger & {
|
||||
httpMethod: 'POST';
|
||||
authentication: 'API_KEY' | null;
|
||||
expectedBody: object;
|
||||
expectedOutputSchema?: object;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { getOutputSchemaMismatchIssues } from '@/logic-function/get-output-schema-mismatch-issues';
|
||||
import {
|
||||
type BaseOutputSchemaV2,
|
||||
type Leaf,
|
||||
type LeafType,
|
||||
type Node,
|
||||
} from '@/workflow/workflow-schema/types/base-output-schema.type';
|
||||
|
||||
const leaf = (type: LeafType, label = 'label'): Leaf => ({
|
||||
isLeaf: true,
|
||||
type,
|
||||
label,
|
||||
value: null,
|
||||
});
|
||||
|
||||
const node = (value: BaseOutputSchemaV2, label = 'label'): Node => ({
|
||||
isLeaf: false,
|
||||
type: 'object',
|
||||
label,
|
||||
value,
|
||||
});
|
||||
|
||||
describe('getOutputSchemaMismatchIssues', () => {
|
||||
it('should return no issues when declared schema matches the expected one', () => {
|
||||
const declared: BaseOutputSchemaV2 = {
|
||||
name: leaf('string'),
|
||||
age: leaf('number'),
|
||||
};
|
||||
const expected: BaseOutputSchemaV2 = {
|
||||
name: leaf('string'),
|
||||
age: leaf('number'),
|
||||
};
|
||||
|
||||
expect(getOutputSchemaMismatchIssues(declared, expected)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore keys present only in the declared schema', () => {
|
||||
const declared: BaseOutputSchemaV2 = {
|
||||
name: leaf('string'),
|
||||
extra: leaf('string'),
|
||||
};
|
||||
const expected: BaseOutputSchemaV2 = {
|
||||
name: leaf('string'),
|
||||
};
|
||||
|
||||
expect(getOutputSchemaMismatchIssues(declared, expected)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should report keys missing from the declared schema', () => {
|
||||
const declared: BaseOutputSchemaV2 = {
|
||||
name: leaf('string'),
|
||||
};
|
||||
const expected: BaseOutputSchemaV2 = {
|
||||
name: leaf('string'),
|
||||
age: leaf('number'),
|
||||
};
|
||||
|
||||
expect(getOutputSchemaMismatchIssues(declared, expected)).toEqual([
|
||||
'Missing key "age" in declared output schema.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should report leaf type mismatches', () => {
|
||||
const declared: BaseOutputSchemaV2 = { age: leaf('string') };
|
||||
const expected: BaseOutputSchemaV2 = { age: leaf('number') };
|
||||
|
||||
expect(getOutputSchemaMismatchIssues(declared, expected)).toEqual([
|
||||
'Type mismatch at "age": expected number but declared string.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should report leaf vs object mismatches', () => {
|
||||
const declared: BaseOutputSchemaV2 = { user: leaf('string') };
|
||||
const expected: BaseOutputSchemaV2 = {
|
||||
user: node({ name: leaf('string') }),
|
||||
};
|
||||
|
||||
expect(getOutputSchemaMismatchIssues(declared, expected)).toEqual([
|
||||
'Type mismatch at "user": expected object but declared string.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should recurse into nested objects with dotted paths', () => {
|
||||
const declared: BaseOutputSchemaV2 = {
|
||||
user: node({ name: leaf('string'), age: leaf('string') }),
|
||||
};
|
||||
const expected: BaseOutputSchemaV2 = {
|
||||
user: node({ name: leaf('string'), age: leaf('number') }),
|
||||
};
|
||||
|
||||
expect(getOutputSchemaMismatchIssues(declared, expected)).toEqual([
|
||||
'Type mismatch at "user.age": expected number but declared string.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not flag mismatches when the expected leaf type is unknown', () => {
|
||||
const declared: BaseOutputSchemaV2 = { maybe: leaf('string') };
|
||||
const expected: BaseOutputSchemaV2 = { maybe: leaf('unknown') };
|
||||
|
||||
expect(getOutputSchemaMismatchIssues(declared, expected)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return no issues for an empty expected schema', () => {
|
||||
expect(getOutputSchemaMismatchIssues({ a: leaf('string') }, {})).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { isDefined } from '@/utils';
|
||||
import { type BaseOutputSchemaV2 } from '@/workflow/workflow-schema/types/base-output-schema.type';
|
||||
|
||||
const buildPath = (parentPath: string, key: string): string =>
|
||||
parentPath ? `${parentPath}.${key}` : key;
|
||||
|
||||
export const getOutputSchemaMismatchIssues = (
|
||||
declaredSchema: BaseOutputSchemaV2,
|
||||
expectedSchema: BaseOutputSchemaV2,
|
||||
parentPath = '',
|
||||
): string[] => {
|
||||
const issues: string[] = [];
|
||||
|
||||
for (const [key, expectedField] of Object.entries(expectedSchema)) {
|
||||
const path = buildPath(parentPath, key);
|
||||
const declaredField = declaredSchema[key];
|
||||
|
||||
if (!isDefined(declaredField)) {
|
||||
issues.push(`Missing key "${path}" in declared output schema.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expectedField.isLeaf !== declaredField.isLeaf) {
|
||||
issues.push(
|
||||
`Type mismatch at "${path}": expected ${
|
||||
expectedField.isLeaf ? expectedField.type : 'object'
|
||||
} but declared ${declaredField.isLeaf ? declaredField.type : 'object'}.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!expectedField.isLeaf && !declaredField.isLeaf) {
|
||||
issues.push(
|
||||
...getOutputSchemaMismatchIssues(
|
||||
declaredField.value,
|
||||
expectedField.value,
|
||||
path,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
expectedField.isLeaf &&
|
||||
declaredField.isLeaf &&
|
||||
expectedField.type !== 'unknown' &&
|
||||
declaredField.type !== 'unknown' &&
|
||||
expectedField.type !== declaredField.type
|
||||
) {
|
||||
issues.push(
|
||||
`Type mismatch at "${path}": expected ${expectedField.type} but declared ${declaredField.type}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
};
|
||||
@@ -11,6 +11,7 @@ export { DEFAULT_TOOL_INPUT_SCHEMA } from './constants/DefaultToolInputSchema';
|
||||
export { SEED_WORKFLOW_ACTION_TRIGGER_SETTINGS } from './constants/SeedWorkflowActionTriggerSettings';
|
||||
export { getInputSchemaFromSourceCode } from './get-input-schema-from-source-code';
|
||||
export { getOutputSchemaFromValue } from './get-output-schema-from-value';
|
||||
export { getOutputSchemaMismatchIssues } from './get-output-schema-mismatch-issues';
|
||||
export type { InputJsonSchema } from './input-json-schema.type';
|
||||
export { inputSchemaToOutputSchema } from './input-schema-to-output-schema';
|
||||
export { jsonSchemaToInputSchema } from './json-schema-to-input-schema';
|
||||
|
||||
@@ -34,6 +34,7 @@ export { workflowDeleteRecordActionSettingsSchema } from './schemas/delete-recor
|
||||
export { workflowDraftEmailActionSchema } from './schemas/draft-email-action-schema';
|
||||
export { workflowEmptyActionSchema } from './schemas/empty-action-schema';
|
||||
export { workflowEmptyActionSettingsSchema } from './schemas/empty-action-settings-schema';
|
||||
export { expectedOutputSchemaShape } from './schemas/expected-output-schema-shape';
|
||||
export { workflowFilterActionSchema } from './schemas/filter-action-schema';
|
||||
export { workflowFilterActionSettingsSchema } from './schemas/filter-action-settings-schema';
|
||||
export { workflowFindRecordsActionSchema } from './schemas/find-records-action-schema';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { baseWorkflowActionSettingsSchema } from './base-workflow-action-settings-schema';
|
||||
import { expectedOutputSchemaShape } from './expected-output-schema-shape';
|
||||
|
||||
export const workflowCodeActionSettingsSchema =
|
||||
baseWorkflowActionSettingsSchema.extend({
|
||||
@@ -15,4 +16,5 @@ export const workflowCodeActionSettingsSchema =
|
||||
'Key-value map of input parameters to pass to the logic function at runtime.',
|
||||
),
|
||||
}),
|
||||
...expectedOutputSchemaShape,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const expectedOutputSchemaShape = {
|
||||
expectedOutputSchema: z
|
||||
.looseObject({})
|
||||
.optional()
|
||||
.describe(
|
||||
'A sample output value declared by the user for steps whose output structure is only known at runtime.',
|
||||
),
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { baseWorkflowActionSettingsSchema } from './base-workflow-action-settings-schema';
|
||||
import { expectedOutputSchemaShape } from './expected-output-schema-shape';
|
||||
|
||||
export const workflowHttpRequestActionSettingsSchema =
|
||||
baseWorkflowActionSettingsSchema.extend({
|
||||
@@ -21,4 +22,5 @@ export const workflowHttpRequestActionSettingsSchema =
|
||||
.or(z.string())
|
||||
.optional(),
|
||||
}),
|
||||
...expectedOutputSchemaShape,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { baseWorkflowActionSettingsSchema } from './base-workflow-action-settings-schema';
|
||||
import { expectedOutputSchemaShape } from './expected-output-schema-shape';
|
||||
|
||||
export const workflowLogicFunctionActionSettingsSchema =
|
||||
baseWorkflowActionSettingsSchema.extend({
|
||||
@@ -7,4 +8,5 @@ export const workflowLogicFunctionActionSettingsSchema =
|
||||
logicFunctionId: z.string(),
|
||||
logicFunctionInput: z.record(z.string(), z.any()),
|
||||
}),
|
||||
...expectedOutputSchemaShape,
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ export const workflowWebhookTriggerSchema = baseTriggerSchema.extend({
|
||||
}),
|
||||
z.object({
|
||||
outputSchema: z.looseObject({}),
|
||||
expectedOutputSchema: z.looseObject({}).optional(),
|
||||
httpMethod: z.literal('POST'),
|
||||
expectedBody: z.looseObject({}),
|
||||
authentication: z.literal('API_KEY').nullable(),
|
||||
|
||||
@@ -24,12 +24,15 @@ export type WorkflowValidationIssueCode =
|
||||
| 'IF_ELSE_INSUFFICIENT_BRANCHES'
|
||||
| 'IF_ELSE_BRANCH_HAS_NO_NEXT_STEP'
|
||||
| 'ITERATOR_MISSING_LOOP_BODY'
|
||||
| 'ITERATOR_ITEMS_NOT_ARRAY'
|
||||
| 'VARIABLE_INVALID_PATH'
|
||||
| 'VARIABLE_UNKNOWN_STEP'
|
||||
| 'VARIABLE_NOT_UPSTREAM'
|
||||
| 'VARIABLE_MISSING_OUTPUT_SCHEMA'
|
||||
| 'VARIABLE_PATH_NOT_FOUND'
|
||||
| 'CODE_STEP_MISSING_OUTPUT_SCHEMA'
|
||||
| 'STEP_HAS_NO_VARIABLE_REFERENCE'
|
||||
| 'LOGIC_FUNCTION_OUTPUT_SCHEMA_MISMATCH'
|
||||
| 'AI_AGENT_MISSING_AGENT'
|
||||
| 'AI_AGENT_MISSING_OUTPUT_VARIABLE';
|
||||
|
||||
|
||||
+2
-2
@@ -99,9 +99,9 @@ const validateBranchingStep = (
|
||||
|
||||
if (branches.length < 2) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
severity: 'warning',
|
||||
code: 'IF_ELSE_INSUFFICIENT_BRANCHES',
|
||||
message: `If/Else step "${step.name ?? step.id}" must have at least two branches (a condition branch and an else branch).`,
|
||||
message: `If/Else step "${step.name ?? step.id}" should have at least two branches (a condition branch and an else branch).`,
|
||||
stepId: step.id,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user