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,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';