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 {};
|
||||
};
|
||||
Reference in New Issue
Block a user