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:
Etienne
2026-06-18 10:31:01 +02:00
committed by GitHub
parent b063c7850b
commit 39e00d5853
30 changed files with 1338 additions and 63 deletions
@@ -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}
/>
);
};
@@ -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 && (
@@ -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),
},
});
@@ -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>
@@ -160,6 +160,7 @@ export const WorkflowEditTriggerWebhookForm = ({
...trigger.settings,
httpMethod: 'POST',
expectedBody: parsingResult.data,
expectedOutputSchema: parsingResult.data,
outputSchema,
} satisfies WorkflowWebhookTrigger['settings'],
},
@@ -18,6 +18,9 @@ describe('getWebhookTriggerDefaultSettings', () => {
expectedBody: {
message: 'Workflow was started',
},
expectedOutputSchema: {
message: 'Workflow was started',
},
outputSchema: {
message: {
icon: 'IconVariable',
@@ -27,6 +27,9 @@ export const getWebhookTriggerDefaultSettings = (
expectedBody: {
message: 'Workflow was started',
},
expectedOutputSchema: {
message: 'Workflow was started',
},
authentication: null,
};
}
@@ -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,
@@ -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 {};
};