diff --git a/src/platform/packages/shared/kbn-workflows/graph/build_execution_graph/build_execution_graph.ts b/src/platform/packages/shared/kbn-workflows/graph/build_execution_graph/build_execution_graph.ts index 2863b88e3c1a7..58e560f13e1c1 100644 --- a/src/platform/packages/shared/kbn-workflows/graph/build_execution_graph/build_execution_graph.ts +++ b/src/platform/packages/shared/kbn-workflows/graph/build_execution_graph/build_execution_graph.ts @@ -30,7 +30,9 @@ import type { WhileStep, WorkflowExecuteAsyncStep, WorkflowExecuteStep, + WorkflowFailStep, WorkflowOnFailure, + WorkflowOutputStep, WorkflowRetry, WorkflowSettings, WorkflowYaml, @@ -62,6 +64,7 @@ import type { LoopEnterNode, WaitForInputGraphNode, WorkflowGraphType, + WorkflowOutputGraphNode, } from '../types'; import { isLoopEnterNode } from '../types'; import { createTypedGraph } from '../workflow_graph/create_typed_graph'; @@ -183,6 +186,20 @@ function visitAbstractStep(currentStep: BaseStep, context: GraphBuildContext): W return visitWorkflowExecuteAsyncStep(currentStep as WorkflowExecuteAsyncStep, context); } + if (currentStep.type === 'workflow.output') { + return visitWorkflowOutputStep(currentStep, context); + } + + if (currentStep.type === 'workflow.fail') { + const transformedStep: WorkflowOutputStep = { + ...currentStep, + type: 'workflow.output', + status: 'failed', + with: (currentStep as WorkflowFailStep).with ?? {}, + }; + return visitWorkflowOutputStep(transformedStep, context); + } + return visitAtomicStep(currentStep, context); } @@ -288,6 +305,23 @@ export function visitWorkflowExecuteAsyncStep( return createLeafStepGraph(currentStep, context, 'workflow.executeAsync'); } +export function visitWorkflowOutputStep( + currentStep: BaseStep, + context: GraphBuildContext +): WorkflowGraphType { + const stepId = getStepId(currentStep, context); + const graph = createTypedGraph({ directed: true }); + const workflowOutputNode: WorkflowOutputGraphNode = { + id: stepId, + type: 'workflow.output', + stepId, + stepType: 'workflow.output', + configuration: currentStep as WorkflowOutputStep, + }; + graph.setNode(workflowOutputNode.id, workflowOutputNode); + return graph; +} + export function visitAtomicStep( currentStep: BaseStep, context: GraphBuildContext diff --git a/src/platform/packages/shared/kbn-workflows/graph/index.ts b/src/platform/packages/shared/kbn-workflows/graph/index.ts index e548e18c19f20..ee389a2b9adb0 100644 --- a/src/platform/packages/shared/kbn-workflows/graph/index.ts +++ b/src/platform/packages/shared/kbn-workflows/graph/index.ts @@ -57,6 +57,8 @@ export type { WorkflowExecuteGraphNodeSchema, WorkflowExecuteAsyncGraphNode, WorkflowExecuteAsyncGraphNodeSchema, + WorkflowOutputGraphNode, + WorkflowOutputGraphNodeSchema, LoopEnterNode, } from './types'; diff --git a/src/platform/packages/shared/kbn-workflows/graph/types/guards.ts b/src/platform/packages/shared/kbn-workflows/graph/types/guards.ts index d7c021437bb0e..baf1777393f1f 100644 --- a/src/platform/packages/shared/kbn-workflows/graph/types/guards.ts +++ b/src/platform/packages/shared/kbn-workflows/graph/types/guards.ts @@ -14,6 +14,7 @@ import type { KibanaGraphNode, WaitForInputGraphNode, WaitGraphNode, + WorkflowOutputGraphNode, } from './nodes/base'; import type { EnterConditionBranchNode, @@ -60,6 +61,9 @@ export const isWaitForInput = (node: GraphNodeUnion): node is WaitForInputGraphN export const isDataSet = (node: GraphNodeUnion): node is DataSetGraphNode => node.type === 'data.set'; +export const isWorkflowOutput = (node: GraphNodeUnion): node is WorkflowOutputGraphNode => + node.type === 'workflow.output'; + export const isEnterIf = (node: GraphNodeUnion): node is EnterIfNode => node.type === 'enter-if'; export const isExitIf = (node: GraphNodeUnion): node is ExitIfNode => node.type === 'exit-if'; diff --git a/src/platform/packages/shared/kbn-workflows/graph/types/index.ts b/src/platform/packages/shared/kbn-workflows/graph/types/index.ts index 8866dd25abea6..9b8eb87b0982f 100644 --- a/src/platform/packages/shared/kbn-workflows/graph/types/index.ts +++ b/src/platform/packages/shared/kbn-workflows/graph/types/index.ts @@ -24,6 +24,8 @@ export type { WorkflowExecuteGraphNodeSchema, WorkflowExecuteAsyncGraphNode, WorkflowExecuteAsyncGraphNodeSchema, + WorkflowOutputGraphNode, + WorkflowOutputGraphNodeSchema, } from './nodes/base'; export type { EnterConditionBranchNode, @@ -89,6 +91,7 @@ export { isKibana, isWait, isWaitForInput, + isWorkflowOutput, isEnterForeach, isEnterWhile, isEnterIf, diff --git a/src/platform/packages/shared/kbn-workflows/graph/types/nodes/base.ts b/src/platform/packages/shared/kbn-workflows/graph/types/nodes/base.ts index 0270d3c1e77da..094455eae32e7 100644 --- a/src/platform/packages/shared/kbn-workflows/graph/types/nodes/base.ts +++ b/src/platform/packages/shared/kbn-workflows/graph/types/nodes/base.ts @@ -16,6 +16,7 @@ import { WaitStepSchema, WorkflowExecuteAsyncStepSchema, WorkflowExecuteStepSchema, + WorkflowOutputStepSchema, } from '../../../spec/schema'; export const GraphNodeSchema = z.object({ @@ -84,3 +85,10 @@ export const WorkflowExecuteAsyncGraphNodeSchema = GraphNodeSchema.extend({ configuration: WorkflowExecuteAsyncStepSchema, }); export type WorkflowExecuteAsyncGraphNode = z.infer; + +export const WorkflowOutputGraphNodeSchema = GraphNodeSchema.extend({ + id: z.string(), + type: z.literal('workflow.output'), + configuration: WorkflowOutputStepSchema, +}); +export type WorkflowOutputGraphNode = z.infer; diff --git a/src/platform/packages/shared/kbn-workflows/graph/types/nodes/union.ts b/src/platform/packages/shared/kbn-workflows/graph/types/nodes/union.ts index 432bc14647ec2..3863634eca704 100644 --- a/src/platform/packages/shared/kbn-workflows/graph/types/nodes/union.ts +++ b/src/platform/packages/shared/kbn-workflows/graph/types/nodes/union.ts @@ -17,6 +17,7 @@ import { WaitGraphNodeSchema, WorkflowExecuteAsyncGraphNodeSchema, WorkflowExecuteGraphNodeSchema, + WorkflowOutputGraphNodeSchema, } from './base'; import { EnterConditionBranchNodeSchema, @@ -58,6 +59,7 @@ const GraphNodeUnionSchema = z.discriminatedUnion('type', [ WaitForInputGraphNodeSchema, WorkflowExecuteGraphNodeSchema, WorkflowExecuteAsyncGraphNodeSchema, + WorkflowOutputGraphNodeSchema, EnterIfNodeSchema, ExitIfNodeSchema, EnterConditionBranchNodeSchema, diff --git a/src/platform/packages/shared/kbn-workflows/index.ts b/src/platform/packages/shared/kbn-workflows/index.ts index f134e326cf7f3..1a965b62410e5 100644 --- a/src/platform/packages/shared/kbn-workflows/index.ts +++ b/src/platform/packages/shared/kbn-workflows/index.ts @@ -7,6 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +export * from './spec/lib/build_fields_zod_validator'; export * from './spec/lib/build_step_schema_for_agent'; export * from './spec/lib/generate_yaml_schema_from_connectors'; export * from './spec/lib/get_workflow_json_schema'; diff --git a/src/platform/plugins/shared/workflows_management/common/lib/json_schema_to_zod.test.ts b/src/platform/packages/shared/kbn-workflows/spec/lib/build_fields_zod_validator.test.ts similarity index 96% rename from src/platform/plugins/shared/workflows_management/common/lib/json_schema_to_zod.test.ts rename to src/platform/packages/shared/kbn-workflows/spec/lib/build_fields_zod_validator.test.ts index 9a8b25d165ae3..cecb8fd2746d2 100644 --- a/src/platform/plugins/shared/workflows_management/common/lib/json_schema_to_zod.test.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/lib/build_fields_zod_validator.test.ts @@ -9,10 +9,10 @@ import type { JSONSchema7 } from 'json-schema'; import { - buildInputsZodValidator, + buildFieldsZodValidator, convertJsonSchemaToZod, convertJsonSchemaToZodWithRefs, -} from './json_schema_to_zod'; +} from './build_fields_zod_validator'; describe('convertJsonSchemaToZod', () => { it('should convert a string schema to Zod', () => { @@ -384,11 +384,11 @@ describe('convertJsonSchemaToZodWithRefs', () => { }); }); -describe('buildInputsZodValidator', () => { +describe('buildFieldsZodValidator', () => { it('should return empty object schema when schema has no properties', () => { - const validator = buildInputsZodValidator(null); + const validator = buildFieldsZodValidator(null); expect(validator.parse({})).toEqual({}); - const validator2 = buildInputsZodValidator({}); + const validator2 = buildFieldsZodValidator({}); expect(validator2.parse({})).toEqual({}); }); @@ -400,8 +400,8 @@ describe('buildInputsZodValidator', () => { age: { type: 'number' }, }, required: ['name'], - } as Parameters[0]; - const validator = buildInputsZodValidator(schema); + } as Parameters[0]; + const validator = buildFieldsZodValidator(schema); expect(validator.parse({ name: 'Alice' })).toEqual({ name: 'Alice' }); expect(validator.parse({ name: 'Bob', age: 30 })).toEqual({ name: 'Bob', age: 30 }); expect(validator.safeParse({ age: 30 }).success).toBe(false); @@ -414,8 +414,8 @@ describe('buildInputsZodValidator', () => { name: { type: 'string', default: 'unknown' }, count: { type: 'number', default: 0 }, }, - } as Parameters[0]; - const validator = buildInputsZodValidator(schema); + } as Parameters[0]; + const validator = buildFieldsZodValidator(schema); const result = validator.parse({}) as { name: string; count: number }; expect(result.name).toBe('unknown'); expect(result.count).toBe(0); @@ -427,8 +427,8 @@ describe('buildInputsZodValidator', () => { properties: { enabled: { type: 'boolean' }, }, - } as Parameters[0]; - const validator = buildInputsZodValidator(schema); + } as Parameters[0]; + const validator = buildFieldsZodValidator(schema); expect(validator.safeParse({ enabled: 'yes' }).success).toBe(false); expect(validator.safeParse({ enabled: true }).success).toBe(true); }); diff --git a/src/platform/plugins/shared/workflows_management/common/lib/json_schema_to_zod.ts b/src/platform/packages/shared/kbn-workflows/spec/lib/build_fields_zod_validator.ts similarity index 59% rename from src/platform/plugins/shared/workflows_management/common/lib/json_schema_to_zod.ts rename to src/platform/packages/shared/kbn-workflows/spec/lib/build_fields_zod_validator.ts index 17102fdee0df7..300dfee97de0f 100644 --- a/src/platform/plugins/shared/workflows_management/common/lib/json_schema_to_zod.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/lib/build_fields_zod_validator.ts @@ -8,74 +8,34 @@ */ import type { JSONSchema7 } from 'json-schema'; -import { resolveRef } from '@kbn/workflows/spec/lib/input_conversion'; import { z } from '@kbn/zod/v4'; import { fromJSONSchema } from '@kbn/zod/v4/from_json_schema'; +import { resolveRef } from './field_conversion'; /** Root schema type for $ref resolution (same as resolveRef's second parameter). */ type RootSchemaType = Parameters[1]; /** - * Recursively converts a JSON Schema to a Zod schema - * This is only used as a fallback for $ref references, which fromJSONSchema doesn't handle - * For non-$ref schemas, we use fromJSONSchema directly in the main function + * Converts a JSON Schema to a Zod schema using fromJSONSchema. + * Used as fallback when fromJSONSchema returns undefined or for $ref placeholders. */ -function convertJsonSchemaToZodRecursive(jsonSchema: JSONSchema7 | null | undefined): z.ZodType { - // Defensive check: handle null/undefined schemas +export function convertJsonSchemaToZod(jsonSchema: JSONSchema7 | null | undefined): z.ZodType { if (!jsonSchema || typeof jsonSchema !== 'object') { return z.any(); } - - // For $ref schemas, we can't use fromJSONSchema directly - // Return z.any() as a placeholder - $ref should be resolved before calling this if (jsonSchema.$ref) { return z.any(); } - - // Try fromJSONSchema - it handles most cases const zodSchema = fromJSONSchema(jsonSchema as Record); if (zodSchema !== undefined) { return zodSchema; } - - // If fromJSONSchema fails, return z.any() as last resort - // This should be rare since fromJSONSchema handles most JSON Schema features return z.any(); } -/** - * Converts a JSON Schema to a Zod schema using fromJSONSchema polyfill - * @param jsonSchema - The JSON Schema to convert - * @returns A Zod schema equivalent to the JSON Schema - */ -export function convertJsonSchemaToZod(jsonSchema: JSONSchema7 | null | undefined): z.ZodType { - // Defensive check: handle null/undefined schemas (crash prevention) - if (!jsonSchema || typeof jsonSchema !== 'object') { - return z.any(); - } - - // Note: fromJSONSchema doesn't handle $ref, so we need to resolve them first - // For now, if there's a $ref, fall back to recursive converter - // TODO: Resolve $ref before calling fromJSONSchema when remote ref support is added - if (jsonSchema.$ref) { - return convertJsonSchemaToZodRecursive(jsonSchema); - } - - // Use fromJSONSchema polyfill - it handles objects, arrays, strings, numbers, booleans, - // enums, defaults, required fields, validation constraints, etc. - const zodSchema = fromJSONSchema(jsonSchema as Record); - if (zodSchema !== undefined) { - return zodSchema; - } - - // If fromJSONSchema returns undefined (should be rare), fall back to recursive converter - // This is a safety net for edge cases the polyfill might not handle - return convertJsonSchemaToZodRecursive(jsonSchema); -} - /** * Recursively converts a JSON Schema to a Zod schema, resolving $ref against the root schema. - * Use this when you have a root schema (e.g. workflow inputsSchema) and property schemas that may reference it. + * Use when you have a root schema (e.g. workflow inputs/outputs) and property schemas that may reference it. */ export function convertJsonSchemaToZodWithRefs( jsonSchema: JSONSchema7, @@ -114,10 +74,11 @@ export function convertJsonSchemaToZodWithRefs( } /** - * Builds a Zod validator for workflow inputs from a normalized JSON Schema (object with properties, required, etc.). - * Handles required/optional, defaults, and $ref resolution. Use after normalizeInputsToJsonSchema when validating input payloads. + * Builds a Zod validator for workflow fields (inputs or outputs) from a normalized JSON Schema + * (object with properties, required, etc.). Handles required/optional, defaults, and $ref resolution. + * Use after normalizeFieldsToJsonSchema when validating field payloads. */ -export function buildInputsZodValidator( +export function buildFieldsZodValidator( schema: RootSchemaType | null | undefined ): z.ZodType> { if (!schema?.properties || typeof schema.properties !== 'object') { diff --git a/src/platform/packages/shared/kbn-workflows/spec/lib/input_conversion.test.ts b/src/platform/packages/shared/kbn-workflows/spec/lib/field_conversion.test.ts similarity index 90% rename from src/platform/packages/shared/kbn-workflows/spec/lib/input_conversion.test.ts rename to src/platform/packages/shared/kbn-workflows/spec/lib/field_conversion.test.ts index 295731c885450..a880b6bfedb98 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/lib/input_conversion.test.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/lib/field_conversion.test.ts @@ -10,13 +10,13 @@ import type { z } from '@kbn/zod/v4'; import { applyInputDefaults, - convertLegacyInputsToJsonSchema, - normalizeInputsToJsonSchema, -} from './input_conversion'; + convertLegacyFieldsToJsonSchema, + normalizeFieldsToJsonSchema, +} from './field_conversion'; import type { WorkflowInputSchema } from '../schema'; import type { JsonModelSchemaType } from '../schema/common/json_model_schema'; -describe('convertLegacyInputsToJsonSchema', () => { +describe('convertLegacyFieldsToJsonSchema', () => { it('should convert array of legacy inputs to JSON Schema object format', () => { const legacyInputs = [ { @@ -33,7 +33,7 @@ describe('convertLegacyInputsToJsonSchema', () => { }, ]; - const result = convertLegacyInputsToJsonSchema( + const result = convertLegacyFieldsToJsonSchema( legacyInputs as Array> ); @@ -64,7 +64,7 @@ describe('convertLegacyInputsToJsonSchema', () => { }, ]; - const result = convertLegacyInputsToJsonSchema( + const result = convertLegacyFieldsToJsonSchema( legacyInputs as Array> ); @@ -85,7 +85,7 @@ describe('convertLegacyInputsToJsonSchema', () => { }, ]; - const result = convertLegacyInputsToJsonSchema( + const result = convertLegacyFieldsToJsonSchema( legacyInputs as Array> ); @@ -100,7 +100,7 @@ describe('convertLegacyInputsToJsonSchema', () => { }); it('should handle empty array', () => { - const result = convertLegacyInputsToJsonSchema([]); + const result = convertLegacyFieldsToJsonSchema([]); expect(result).toEqual({ properties: {}, additionalProperties: false, @@ -116,7 +116,7 @@ describe('convertLegacyInputsToJsonSchema', () => { }, ]; - const result = convertLegacyInputsToJsonSchema( + const result = convertLegacyFieldsToJsonSchema( legacyInputs as Array> ); @@ -138,7 +138,7 @@ describe('convertLegacyInputsToJsonSchema', () => { }, ]; - const result = convertLegacyInputsToJsonSchema(legacyInputs as any); + const result = convertLegacyFieldsToJsonSchema(legacyInputs as any); expect(result.properties?.username).toEqual({ type: 'string' }); expect(result.properties?.age).toEqual({ type: 'number' }); @@ -148,7 +148,7 @@ describe('convertLegacyInputsToJsonSchema', () => { }); }); -describe('normalizeInputsToJsonSchema', () => { +describe('normalizeFieldsToJsonSchema', () => { it('should return new format inputs as-is', () => { const inputs: JsonModelSchemaType = { properties: { @@ -161,7 +161,7 @@ describe('normalizeInputsToJsonSchema', () => { additionalProperties: false, }; - const result = normalizeInputsToJsonSchema(inputs); + const result = normalizeFieldsToJsonSchema(inputs); expect(result).toEqual(inputs); }); @@ -174,7 +174,7 @@ describe('normalizeInputsToJsonSchema', () => { }, ]; - const result = normalizeInputsToJsonSchema(legacyInputs as any); + const result = normalizeFieldsToJsonSchema(legacyInputs as any); expect(result?.properties?.username).toEqual({ type: 'string', @@ -183,60 +183,60 @@ describe('normalizeInputsToJsonSchema', () => { }); it('should return undefined for undefined input', () => { - const result = normalizeInputsToJsonSchema(undefined); + const result = normalizeFieldsToJsonSchema(undefined); expect(result).toBeUndefined(); }); describe('edge cases - partially parsed YAML (defensive checks)', () => { it('should handle string input (partially typed "properties")', () => { // Simulates user typing "properties:" in YAML editor - const result = normalizeInputsToJsonSchema('properties' as any); + const result = normalizeFieldsToJsonSchema('properties' as any); expect(result).toBeUndefined(); }); it('should handle number input', () => { - const result = normalizeInputsToJsonSchema(123 as any); + const result = normalizeFieldsToJsonSchema(123 as any); expect(result).toBeUndefined(); }); it('should handle boolean input', () => { - const result = normalizeInputsToJsonSchema(true as any); + const result = normalizeFieldsToJsonSchema(true as any); expect(result).toBeUndefined(); }); it('should handle null input', () => { - const result = normalizeInputsToJsonSchema(null as any); + const result = normalizeFieldsToJsonSchema(null as any); expect(result).toBeUndefined(); }); it('should handle empty string input', () => { - const result = normalizeInputsToJsonSchema('' as any); + const result = normalizeFieldsToJsonSchema('' as any); expect(result).toBeUndefined(); }); it('should handle object without properties key', () => { - const result = normalizeInputsToJsonSchema({ foo: 'bar' } as any); + const result = normalizeFieldsToJsonSchema({ foo: 'bar' } as any); expect(result).toBeUndefined(); }); it('should handle object with null properties', () => { - const result = normalizeInputsToJsonSchema({ properties: null } as any); + const result = normalizeFieldsToJsonSchema({ properties: null } as any); expect(result).toBeUndefined(); }); it('should handle object with string properties', () => { - const result = normalizeInputsToJsonSchema({ properties: 'invalid' } as any); + const result = normalizeFieldsToJsonSchema({ properties: 'invalid' } as any); expect(result).toBeUndefined(); }); it('should handle object with array properties (invalid)', () => { - const result = normalizeInputsToJsonSchema({ properties: [] } as any); + const result = normalizeFieldsToJsonSchema({ properties: [] } as any); // Should return undefined or handle gracefully (array is not valid properties) expect(result).toBeUndefined(); }); it('should handle object with properties containing null values', () => { - const result = normalizeInputsToJsonSchema({ + const result = normalizeFieldsToJsonSchema({ properties: { name: null, age: { type: 'number' }, @@ -248,7 +248,7 @@ describe('normalizeInputsToJsonSchema', () => { }); it('should handle object with properties containing string values (invalid schema)', () => { - const result = normalizeInputsToJsonSchema({ + const result = normalizeFieldsToJsonSchema({ properties: { name: 'invalid string schema', age: { type: 'number' }, @@ -259,7 +259,7 @@ describe('normalizeInputsToJsonSchema', () => { }); it('should handle deeply nested null values', () => { - const result = normalizeInputsToJsonSchema({ + const result = normalizeFieldsToJsonSchema({ properties: { user: { type: 'object', @@ -303,14 +303,14 @@ describe('normalizeInputsToJsonSchema', () => { additionalProperties: false, }; - const result = normalizeInputsToJsonSchema(inputs); + const result = normalizeFieldsToJsonSchema(inputs); expect(result).toEqual(inputs); }); }); describe('applyInputDefaults', () => { it('should apply defaults when inputs are undefined', () => { - const inputsSchema = normalizeInputsToJsonSchema({ + const inputsSchema = normalizeFieldsToJsonSchema({ properties: { name: { type: 'string', default: 'Default Name' }, age: { type: 'number', default: 25 }, @@ -327,7 +327,7 @@ describe('applyInputDefaults', () => { }); it('should apply defaults for missing properties', () => { - const inputsSchema = normalizeInputsToJsonSchema({ + const inputsSchema = normalizeFieldsToJsonSchema({ properties: { name: { type: 'string', default: 'Default Name' }, age: { type: 'number', default: 25 }, @@ -344,7 +344,7 @@ describe('applyInputDefaults', () => { }); it('should apply defaults for nested objects', () => { - const inputsSchema = normalizeInputsToJsonSchema({ + const inputsSchema = normalizeFieldsToJsonSchema({ properties: { analyst: { type: 'object', @@ -372,7 +372,7 @@ describe('applyInputDefaults', () => { }); it('should apply defaults for partial nested objects', () => { - const inputsSchema = normalizeInputsToJsonSchema({ + const inputsSchema = normalizeFieldsToJsonSchema({ properties: { analyst: { type: 'object', @@ -400,7 +400,7 @@ describe('applyInputDefaults', () => { }); it('should apply defaults for arrays', () => { - const inputsSchema = normalizeInputsToJsonSchema({ + const inputsSchema = normalizeFieldsToJsonSchema({ properties: { notifyTeams: { type: 'array', @@ -418,7 +418,7 @@ describe('applyInputDefaults', () => { }); it('should apply defaults for $ref references', () => { - const inputsSchema = normalizeInputsToJsonSchema({ + const inputsSchema = normalizeFieldsToJsonSchema({ properties: { user: { $ref: '#/definitions/UserSchema', @@ -461,7 +461,7 @@ describe('applyInputDefaults', () => { }); it('should pre-fill Threat Intelligence Enrichment workflow with all defaults', () => { - const inputsSchema = normalizeInputsToJsonSchema({ + const inputsSchema = normalizeFieldsToJsonSchema({ properties: { analyst: { type: 'object', @@ -636,7 +636,7 @@ describe('applyInputDefaults', () => { }); it('should handle the security workflow example with all defaults', () => { - const inputsSchema = normalizeInputsToJsonSchema({ + const inputsSchema = normalizeFieldsToJsonSchema({ properties: { analyst: { type: 'object', diff --git a/src/platform/packages/shared/kbn-workflows/spec/lib/field_conversion.ts b/src/platform/packages/shared/kbn-workflows/spec/lib/field_conversion.ts new file mode 100644 index 0000000000000..3d62897a47672 --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/spec/lib/field_conversion.ts @@ -0,0 +1,391 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { JSONSchema7 } from 'json-schema'; +import type { z } from '@kbn/zod/v4'; +import type { LegacyWorkflowInput, WorkflowInputSchema, WorkflowOutput } from '../schema'; +import type { JsonModelSchemaType } from '../schema/common/json_model_schema'; +import type { JsonSchema } from '../schema/common/json_model_shape_schema'; + +export type NormalizableFieldSchema = + | JsonModelSchemaType + | Array; + +/** + * Converts a legacy workflow field definition to a JSON Schema property + * @param field - The legacy field to convert (input or output) + * @returns A JSON Schema property definition + */ +function convertLegacyFieldToJsonSchemaProperty(field: LegacyWorkflowInput): JSONSchema7 { + const property: JSONSchema7 = { + type: field.type === 'choice' ? 'string' : field.type, + }; + + if (field.description) { + property.description = field.description; + } + + if (field.default !== undefined) { + property.default = field.default; + } + + switch (field.type) { + case 'choice': + property.enum = field.options; + break; + case 'array': { + property.items = { + anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }], + }; + if (field.minItems !== undefined) { + property.minItems = field.minItems; + } + if (field.maxItems !== undefined) { + property.maxItems = field.maxItems; + } + break; + } + default: + break; + } + + return property; +} + +/** + * Converts legacy array-based field format to the new JSON Schema object format + * @param legacyFields - Array of legacy field definitions (inputs or outputs) + * @returns The fields in the new JSON Schema object format + */ +export function convertLegacyFieldsToJsonSchema( + legacyFields: Array> +): JsonModelSchemaType { + const properties: Record = {}; + const required: string[] = []; + + for (const field of legacyFields) { + if (field && typeof field === 'object' && field.name) { + properties[field.name] = convertLegacyFieldToJsonSchemaProperty( + field + ) as unknown as JsonSchema; + + if (field.required === true) { + required.push(field.name); + } + } + } + + return { + properties, + ...(required.length > 0 ? { required } : {}), + additionalProperties: false, + }; +} + +/** + * Normalizes workflow fields (inputs or outputs) to the JSON Schema object format. + * If fields are already in JSON Schema format, returns them as-is. + * If fields are in the legacy array format, converts them. + * Accepts unknown to avoid explicit casts at call sites (runtime checks handle validation). + */ +export function normalizeFieldsToJsonSchema( + fields?: NormalizableFieldSchema | unknown +): JsonModelSchemaType | undefined { + if (!fields) { + return undefined; + } + + // Check typeof first to avoid 'in' operator error on primitives (e.g., when YAML is partially parsed) + if ( + typeof fields === 'object' && + fields !== null && + !Array.isArray(fields) && + 'properties' in fields + ) { + const fieldsWithProperties = fields as { properties?: unknown }; + if ( + typeof fieldsWithProperties.properties === 'object' && + fieldsWithProperties.properties !== null && + !Array.isArray(fieldsWithProperties.properties) + ) { + return fields as JsonModelSchemaType; + } + } + + if (Array.isArray(fields)) { + return convertLegacyFieldsToJsonSchema(fields as LegacyWorkflowInput[]); + } + + return undefined; +} + +/** + * Applies defaults to a nested object property + * @param prop - The property schema + * @param currentValue - The current value for this property + * @returns The value with defaults applied + */ +function applyDefaultToObjectProperty( + prop: JSONSchema7, + currentValue: unknown, + inputsSchema?: ReturnType +): unknown { + if (currentValue === undefined) { + if (prop.default !== undefined) { + return prop.default; + } + if (prop.type === 'object' && prop.properties) { + return applyDefaultFromSchema(prop, undefined, inputsSchema); + } + return undefined; + } + + if ( + prop.type === 'object' && + prop.properties && + typeof currentValue === 'object' && + !Array.isArray(currentValue) + ) { + return applyDefaultFromSchema(prop, currentValue, inputsSchema); + } + + return currentValue; +} + +/** + * Applies defaults to all properties of an object + * @param schema - The object schema + * @param value - The current object value + * @returns The object with defaults applied + */ +function applyDefaultsToObjectProperties( + schema: JSONSchema7, + value: Record, + inputsSchema?: ReturnType +): Record { + const result: Record = { ...value }; + for (const [key, propSchema] of Object.entries(schema.properties || {})) { + const prop = propSchema as JSONSchema7; + const currentValue = result[key]; + const defaultValue = applyDefaultToObjectProperty(prop, currentValue, inputsSchema); + if (defaultValue !== undefined) { + result[key] = defaultValue; + } + } + return result; +} + +/** + * Recursively checks if a schema has any defaults (direct or nested) + */ +function hasDefaultsRecursive( + schema: JSONSchema7, + inputsSchema?: ReturnType +): boolean { + // Resolve $ref if present + if (schema.$ref && inputsSchema) { + const resolvedSchema = resolveRef(schema.$ref, inputsSchema); + if (resolvedSchema) { + return hasDefaultsRecursive(resolvedSchema, inputsSchema); + } + } + + // Direct default + if (schema.default !== undefined) { + return true; + } + + // Check nested properties for defaults + if (schema.type === 'object' && schema.properties) { + for (const propSchema of Object.values(schema.properties)) { + const prop = propSchema as JSONSchema7; + if (hasDefaultsRecursive(prop, inputsSchema)) { + return true; + } + } + } + + // Check array items for defaults + if (schema.type === 'array' && schema.items) { + const itemsSchema = schema.items as JSONSchema7; + return hasDefaultsRecursive(itemsSchema, inputsSchema); + } + + return false; +} + +function createObjectWithDefaults( + schema: JSONSchema7, + inputsSchema?: ReturnType +): Record | undefined { + const result: Record = {}; + for (const [key, propSchema] of Object.entries(schema.properties || {})) { + const prop = propSchema as JSONSchema7; + const isRequired = schema.required?.includes(key) ?? false; + + // Include property if: + // 1. It's required, OR + // 2. It has defaults (direct or nested) + if (isRequired || hasDefaultsRecursive(prop, inputsSchema)) { + const defaultValue = applyDefaultFromSchema(prop, undefined, inputsSchema); + if (defaultValue !== undefined) { + result[key] = defaultValue; + } + } + } + return Object.keys(result).length > 0 ? result : undefined; +} + +/** + * Resolves a $ref reference within the inputs schema context + * @param ref - The $ref string (e.g., "#/definitions/UserSchema") + * @param inputsSchema - The full inputs schema containing definitions + * @returns The resolved schema, or null if not found + */ +export function resolveRef( + ref: string, + inputsSchema: ReturnType +): JSONSchema7 | null { + if (!ref.startsWith('#/')) { + // External references not supported yet + return null; + } + + const path = ref.slice(2).split('/'); // Remove '#/' and split + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let current: any = inputsSchema; + + for (const segment of path) { + if (current && typeof current === 'object') { + current = current[segment]; + } else { + return null; + } + } + + return current as JSONSchema7 | null; +} + +/** + * Recursively applies default values from JSON Schema to input values + * @param schema - The JSON Schema property definition + * @param value - The current input value (may be undefined) + * @param inputsSchema - The full inputs schema (for resolving $ref) + * @returns The value with defaults applied + */ +function applyDefaultFromSchema( + schema: JSONSchema7, + value: unknown, + inputsSchema?: ReturnType +): unknown { + // Resolve $ref if present + if (schema.$ref && inputsSchema) { + const resolvedSchema = resolveRef(schema.$ref, inputsSchema); + if (resolvedSchema) { + // Use resolved schema for applying defaults + return applyDefaultFromSchema(resolvedSchema, value, inputsSchema); + } + } + // If value is already provided, use it (unless it's undefined/null for objects) + if (value !== undefined && value !== null) { + // For objects, recursively apply defaults to nested properties + if ( + schema.type === 'object' && + schema.properties && + typeof value === 'object' && + !Array.isArray(value) + ) { + return applyDefaultsToObjectProperties( + schema, + value as Record, + inputsSchema + ); + } + return value; + } + + // If value is not provided, use default if available + if (schema.default !== undefined) { + return schema.default; + } + + // For objects, create object with defaults for required properties or properties with defaults + if (schema.type === 'object' && schema.properties) { + const objectWithDefaults = createObjectWithDefaults(schema, inputsSchema); + // If we created an object with defaults, return it; otherwise return undefined + // This ensures nested objects with defaults are created even if not required + return objectWithDefaults; + } + + // For arrays, return empty array if no default + if (schema.type === 'array') { + return schema.default ?? []; + } + + return undefined; +} + +/** + * Applies default values from JSON Schema to workflow inputs + * @param inputs - The actual input values provided (may be partial or undefined) + * @param inputsSchema - The normalized JSON Schema inputs definition + * @returns The inputs with defaults applied + */ +export function applyInputDefaults( + inputs: Record | undefined, + inputsSchema: ReturnType +): Record | undefined { + if (!inputsSchema?.properties) { + return inputs; + } + + const result: Record = { ...(inputs || {}) }; + let hasAnyDefaults = false; + + for (const [propertyName, propertySchema] of Object.entries(inputsSchema.properties)) { + const jsonSchema = propertySchema as JSONSchema7; + const currentValue = result[propertyName]; + + // Apply defaults if value is missing or undefined + if (currentValue === undefined) { + const defaultValue = applyDefaultFromSchema(jsonSchema, undefined, inputsSchema); + if (defaultValue !== undefined) { + result[propertyName] = defaultValue; + hasAnyDefaults = true; + } + } else if ( + jsonSchema.type === 'object' && + jsonSchema.properties && + typeof currentValue === 'object' && + !Array.isArray(currentValue) + ) { + // Recursively apply defaults to nested objects + const updatedValue = applyDefaultFromSchema(jsonSchema, currentValue, inputsSchema); + if (updatedValue !== undefined) { + result[propertyName] = updatedValue; + hasAnyDefaults = true; + } + } else if (jsonSchema.$ref) { + // Handle $ref: resolve and apply defaults + const defaultValue = applyDefaultFromSchema(jsonSchema, currentValue, inputsSchema); + if (defaultValue !== undefined) { + result[propertyName] = defaultValue; + hasAnyDefaults = true; + } + } + } + + // Return undefined if no defaults were applied and inputs was undefined + // This allows the caller to distinguish between "no defaults" and "empty object with defaults" + if (!hasAnyDefaults && inputs === undefined && Object.keys(result).length === 0) { + return undefined; + } + + return result; +} diff --git a/src/platform/packages/shared/kbn-workflows/spec/lib/generate_yaml_schema_from_connectors.ts b/src/platform/packages/shared/kbn-workflows/spec/lib/generate_yaml_schema_from_connectors.ts index 2c2c67617addd..a5b133108ee45 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/lib/generate_yaml_schema_from_connectors.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/lib/generate_yaml_schema_from_connectors.ts @@ -8,7 +8,7 @@ */ import { z } from '@kbn/zod/v4'; -import { convertLegacyInputsToJsonSchema } from './input_conversion'; +import { convertLegacyFieldsToJsonSchema } from './field_conversion'; import { type ConnectorContractUnion } from '../..'; import { KIBANA_TYPE_ALIASES } from '../kibana/aliases'; import { @@ -27,6 +27,8 @@ import { WaitStepSchema, WorkflowExecuteAsyncStepSchema, WorkflowExecuteStepSchema, + WorkflowFailStepSchema, + WorkflowOutputStepSchema, WorkflowSchemaBase, WorkflowSchemaForAutocompleteBase, WorkflowSettingsSchema, @@ -82,7 +84,7 @@ export function generateYamlSchemaFromConnectors( ) { normalizedInputs = data.inputs as z.infer; } else if (Array.isArray(data.inputs)) { - normalizedInputs = convertLegacyInputsToJsonSchema(data.inputs); + normalizedInputs = convertLegacyFieldsToJsonSchema(data.inputs); } } const { inputs: _, ...rest } = data; @@ -129,6 +131,8 @@ function createRecursiveStepSchema( DataSetStepSchema, WorkflowExecuteStepSchema, WorkflowExecuteAsyncStepSchema, + WorkflowOutputStepSchema, + WorkflowFailStepSchema, LoopBreakStepSchema, LoopContinueStepSchema, ...connectorSchemas, diff --git a/src/platform/packages/shared/kbn-workflows/spec/lib/get_json_schema_from_yaml_schema.monaco.test.ts b/src/platform/packages/shared/kbn-workflows/spec/lib/get_json_schema_from_yaml_schema.monaco.test.ts index 3a7aa962b2b60..06a64ce4278ba 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/lib/get_json_schema_from_yaml_schema.monaco.test.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/lib/get_json_schema_from_yaml_schema.monaco.test.ts @@ -10,6 +10,18 @@ import { generateYamlSchemaFromConnectors } from './generate_yaml_schema_from_connectors'; import { getWorkflowJsonSchema } from './get_workflow_json_schema'; +function resolveSchemaRef(schema: any, rootSchema: any): any { + if (schema && schema.$ref && typeof schema.$ref === 'string') { + const refPath = schema.$ref.replace('#/', '').split('/'); + let resolved = rootSchema; + for (const segment of refPath) { + resolved = resolved?.[segment]; + } + return resolved || schema; + } + return schema; +} + describe('Monaco Schema Generation - Inputs Field', () => { it('should generate schema without array format for inputs.properties', () => { const workflowZodSchema = generateYamlSchemaFromConnectors([]); @@ -41,10 +53,11 @@ describe('Monaco Schema Generation - Inputs Field', () => { // Check the inputs schema structure if (inputsSchema?.anyOf && Array.isArray(inputsSchema.anyOf)) { + const resolvedAnyOf = inputsSchema.anyOf.map((s: any) => resolveSchemaRef(s, jsonSchema)); // Should have BOTH array and object schemas for backward compatibility // Array schemas (legacy format) should be kept so Monaco accepts both formats - const arraySchemas = inputsSchema.anyOf.filter((s: any) => s.type === 'array'); - const objectSchemas = inputsSchema.anyOf.filter( + const arraySchemas = resolvedAnyOf.filter((s: any) => s.type === 'array'); + const objectSchemas = resolvedAnyOf.filter( (s: any) => s.type === 'object' && s.type !== 'null' && s.type !== 'undefined' ); // Both formats should be present for backward compatibility @@ -52,7 +65,7 @@ describe('Monaco Schema Generation - Inputs Field', () => { expect(objectSchemas.length).toBeGreaterThan(0); // Find the non-null schema - const nonNullSchema = inputsSchema.anyOf.find( + const nonNullSchema = resolvedAnyOf.find( (s: any) => s.type !== 'null' && s.type !== 'undefined' ); expect(nonNullSchema).toBeDefined(); @@ -76,8 +89,11 @@ describe('Monaco Schema Generation - Inputs Field', () => { } // Verify that inputs.properties is an object, not an array + const objectSchemaFromAnyOf = inputsSchema?.anyOf + ?.map((s: any) => resolveSchemaRef(s, jsonSchema)) + ?.find((s: any) => s.type === 'object'); const propertiesSchema = - inputsSchema?.anyOf?.[0]?.properties?.properties || inputsSchema?.properties?.properties; + objectSchemaFromAnyOf?.properties?.properties || inputsSchema?.properties?.properties; expect(propertiesSchema).toBeDefined(); expect(propertiesSchema.type).toBe('object'); expect(propertiesSchema.type).not.toBe('array'); @@ -116,9 +132,11 @@ describe('Monaco Schema Generation - Inputs Field', () => { // Verify the structure matches what we expect if (inputsSchema?.anyOf) { - const nonNullSchema = inputsSchema.anyOf.find( - (s: any) => s.type !== 'null' && s.type !== 'undefined' - ) as { type?: string; properties?: { properties?: unknown } } | undefined; + const nonNullSchema = inputsSchema.anyOf + .map((s: any) => resolveSchemaRef(s, jsonSchema)) + .find((s: any) => s.type !== 'null' && s.type !== 'undefined') as + | { type?: string; properties?: { properties?: unknown } } + | undefined; expect(nonNullSchema).toBeDefined(); if (nonNullSchema) { expect(nonNullSchema.type).toBe('object'); @@ -156,9 +174,10 @@ describe('Monaco Schema Generation - Inputs Field', () => { } if (inputsSchema?.anyOf && Array.isArray(inputsSchema.anyOf)) { + const resolvedAnyOf = inputsSchema.anyOf.map((s: any) => resolveSchemaRef(s, jsonSchema)); // CRITICAL: Should have BOTH array and object schemas for backward compatibility // Array schemas (legacy format) should be kept so Monaco accepts both formats - const arraySchemas = inputsSchema.anyOf.filter((s: any) => s.type === 'array'); + const arraySchemas = resolvedAnyOf.filter((s: any) => s.type === 'array'); expect(arraySchemas.length).toBeGreaterThan(0); // Array schemas should be present for backward compatibility // Should have null/undefined for optional (if present) @@ -172,7 +191,7 @@ describe('Monaco Schema Generation - Inputs Field', () => { // The critical requirement is that inputs is optional, which is verified by the union structure // Should have object schema (new format) - const objectSchemas = inputsSchema.anyOf.filter( + const objectSchemas = resolvedAnyOf.filter( (s: any) => s.type === 'object' && s.type !== 'null' && s.type !== 'undefined' ); expect(objectSchemas.length).toBeGreaterThan(0); @@ -219,9 +238,9 @@ describe('Monaco Schema Generation - Inputs Field', () => { let actualStepsSchema = stepsSchema; if (stepsSchema?.anyOf && Array.isArray(stepsSchema.anyOf)) { // If wrapped in anyOf, find the non-null schema - const nonNullSchema = stepsSchema.anyOf.find( - (s: any) => s.type !== 'null' && s.type !== 'undefined' - ); + const nonNullSchema = stepsSchema.anyOf + .map((s: any) => resolveSchemaRef(s, jsonSchema)) + .find((s: any) => s.type !== 'null' && s.type !== 'undefined'); if (nonNullSchema) { actualStepsSchema = nonNullSchema; } diff --git a/src/platform/packages/shared/kbn-workflows/spec/lib/input_conversion.ts b/src/platform/packages/shared/kbn-workflows/spec/lib/input_conversion.ts index 2440910b5b05f..1f72b6ca33ce2 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/lib/input_conversion.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/lib/input_conversion.ts @@ -7,387 +7,17 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { JSONSchema7 } from 'json-schema'; -import type { z } from '@kbn/zod/v4'; -import type { LegacyWorkflowInput, WorkflowInputSchema } from '../schema'; +import { normalizeFieldsToJsonSchema } from './field_conversion'; import type { JsonModelSchemaType } from '../schema/common/json_model_schema'; -import type { JsonSchema } from '../schema/common/json_model_shape_schema'; /** - * Converts a legacy workflow input definition to a JSON Schema property - * @param input - The legacy input to convert - * @returns A JSON Schema property definition + * Temporary backward compatibility for consumers that import from input_conversion + * (e.g. agent_builder). Delegates to normalizeFieldsToJsonSchema so no code changes + * are required in those plugins. Accepts unknown to avoid casts at call sites. + * + * @param inputs - The inputs to normalize (legacy array or JSON Schema object) + * @returns The inputs in JSON Schema object format, or undefined */ -function convertLegacyInputToJsonSchemaProperty(input: LegacyWorkflowInput): JSONSchema7 { - const property: JSONSchema7 = { - type: input.type === 'choice' ? 'string' : input.type, - }; - - if (input.description) { - property.description = input.description; - } - - if (input.default !== undefined) { - property.default = input.default; - } - - switch (input.type) { - case 'choice': - property.enum = input.options; - break; - case 'array': { - property.items = { - anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }], - }; - if (input.minItems !== undefined) { - property.minItems = input.minItems; - } - if (input.maxItems !== undefined) { - property.maxItems = input.maxItems; - } - break; - } - default: - // string, number, boolean - already handled - break; - } - - return property; -} - -/** - * Converts legacy array-based inputs format to the new JSON Schema object format - * @param legacyInputs - Array of legacy input definitions - * @returns The inputs in the new JSON Schema object format - */ -export function convertLegacyInputsToJsonSchema( - legacyInputs: Array> -): JsonModelSchemaType { - const properties: Record = {}; - const required: string[] = []; - - for (const input of legacyInputs) { - // Skip null/undefined inputs (can happen during partial YAML parsing) - if (input && typeof input === 'object' && input.name) { - properties[input.name] = convertLegacyInputToJsonSchemaProperty( - input - ) as unknown as JsonSchema; - - if (input.required === true) { - required.push(input.name); - } - } - } - - return { - properties, - ...(required.length > 0 ? { required } : {}), - additionalProperties: false, - }; -} - -/** - * Normalizes workflow inputs to the new JSON Schema object format - * If inputs are already in the new format, returns them as-is - * If inputs are in the legacy array format, converts them - * @param inputs - The inputs to normalize (can be array or JSON Schema object) - * @returns The inputs in the new JSON Schema object format, or undefined if no inputs - */ -export function normalizeInputsToJsonSchema( - inputs?: JsonModelSchemaType | Array> -): JsonModelSchemaType | undefined { - if (!inputs) { - return undefined; - } - - // If it's already in the new format (has properties), return as-is - // Check typeof first to avoid 'in' operator error on primitives (e.g., when YAML is partially parsed) - if ( - typeof inputs === 'object' && - inputs !== null && - !Array.isArray(inputs) && - 'properties' in inputs - ) { - const inputsWithProperties = inputs as { properties?: unknown }; - if ( - typeof inputsWithProperties.properties === 'object' && - inputsWithProperties.properties !== null && - !Array.isArray(inputsWithProperties.properties) - ) { - return inputs as JsonModelSchemaType; - } - } - - // If it's an array, convert it - if (Array.isArray(inputs)) { - return convertLegacyInputsToJsonSchema(inputs); - } - - // Fallback: return undefined - return undefined; -} - -/** - * Applies defaults to a nested object property - * @param prop - The property schema - * @param currentValue - The current value for this property - * @returns The value with defaults applied - */ -function applyDefaultToObjectProperty( - prop: JSONSchema7, - currentValue: unknown, - inputsSchema?: ReturnType -): unknown { - if (currentValue === undefined) { - if (prop.default !== undefined) { - return prop.default; - } - if (prop.type === 'object' && prop.properties) { - return applyDefaultFromSchema(prop, undefined, inputsSchema); - } - return undefined; - } - - if ( - prop.type === 'object' && - prop.properties && - typeof currentValue === 'object' && - !Array.isArray(currentValue) - ) { - return applyDefaultFromSchema(prop, currentValue, inputsSchema); - } - - return currentValue; -} - -/** - * Applies defaults to all properties of an object - * @param schema - The object schema - * @param value - The current object value - * @returns The object with defaults applied - */ -function applyDefaultsToObjectProperties( - schema: JSONSchema7, - value: Record, - inputsSchema?: ReturnType -): Record { - const result: Record = { ...value }; - for (const [key, propSchema] of Object.entries(schema.properties || {})) { - const prop = propSchema as JSONSchema7; - const currentValue = result[key]; - const defaultValue = applyDefaultToObjectProperty(prop, currentValue, inputsSchema); - if (defaultValue !== undefined) { - result[key] = defaultValue; - } - } - return result; -} - -/** - * Recursively checks if a schema has any defaults (direct or nested) - */ -function hasDefaultsRecursive( - schema: JSONSchema7, - inputsSchema?: ReturnType -): boolean { - // Resolve $ref if present - if (schema.$ref && inputsSchema) { - const resolvedSchema = resolveRef(schema.$ref, inputsSchema); - if (resolvedSchema) { - return hasDefaultsRecursive(resolvedSchema, inputsSchema); - } - } - - // Direct default - if (schema.default !== undefined) { - return true; - } - - // Check nested properties for defaults - if (schema.type === 'object' && schema.properties) { - for (const propSchema of Object.values(schema.properties)) { - const prop = propSchema as JSONSchema7; - if (hasDefaultsRecursive(prop, inputsSchema)) { - return true; - } - } - } - - // Check array items for defaults - if (schema.type === 'array' && schema.items) { - const itemsSchema = schema.items as JSONSchema7; - return hasDefaultsRecursive(itemsSchema, inputsSchema); - } - - return false; -} - -function createObjectWithDefaults( - schema: JSONSchema7, - inputsSchema?: ReturnType -): Record | undefined { - const result: Record = {}; - for (const [key, propSchema] of Object.entries(schema.properties || {})) { - const prop = propSchema as JSONSchema7; - const isRequired = schema.required?.includes(key) ?? false; - - // Include property if: - // 1. It's required, OR - // 2. It has defaults (direct or nested) - if (isRequired || hasDefaultsRecursive(prop, inputsSchema)) { - const defaultValue = applyDefaultFromSchema(prop, undefined, inputsSchema); - if (defaultValue !== undefined) { - result[key] = defaultValue; - } - } - } - return Object.keys(result).length > 0 ? result : undefined; -} - -/** - * Resolves a $ref reference within the inputs schema context - * @param ref - The $ref string (e.g., "#/definitions/UserSchema") - * @param inputsSchema - The full inputs schema containing definitions - * @returns The resolved schema, or null if not found - */ -export function resolveRef( - ref: string, - inputsSchema: ReturnType -): JSONSchema7 | null { - if (!ref.startsWith('#/')) { - // External references not supported yet - return null; - } - - const path = ref.slice(2).split('/'); // Remove '#/' and split - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let current: any = inputsSchema; - - for (const segment of path) { - if (current && typeof current === 'object') { - current = current[segment]; - } else { - return null; - } - } - - return current as JSONSchema7 | null; -} - -/** - * Recursively applies default values from JSON Schema to input values - * @param schema - The JSON Schema property definition - * @param value - The current input value (may be undefined) - * @param inputsSchema - The full inputs schema (for resolving $ref) - * @returns The value with defaults applied - */ -function applyDefaultFromSchema( - schema: JSONSchema7, - value: unknown, - inputsSchema?: ReturnType -): unknown { - // Resolve $ref if present - if (schema.$ref && inputsSchema) { - const resolvedSchema = resolveRef(schema.$ref, inputsSchema); - if (resolvedSchema) { - // Use resolved schema for applying defaults - return applyDefaultFromSchema(resolvedSchema, value, inputsSchema); - } - } - // If value is already provided, use it (unless it's undefined/null for objects) - if (value !== undefined && value !== null) { - // For objects, recursively apply defaults to nested properties - if ( - schema.type === 'object' && - schema.properties && - typeof value === 'object' && - !Array.isArray(value) - ) { - return applyDefaultsToObjectProperties( - schema, - value as Record, - inputsSchema - ); - } - return value; - } - - // If value is not provided, use default if available - if (schema.default !== undefined) { - return schema.default; - } - - // For objects, create object with defaults for required properties or properties with defaults - if (schema.type === 'object' && schema.properties) { - const objectWithDefaults = createObjectWithDefaults(schema, inputsSchema); - // If we created an object with defaults, return it; otherwise return undefined - // This ensures nested objects with defaults are created even if not required - return objectWithDefaults; - } - - // For arrays, return empty array if no default - if (schema.type === 'array') { - return schema.default ?? []; - } - - return undefined; -} - -/** - * Applies default values from JSON Schema to workflow inputs - * @param inputs - The actual input values provided (may be partial or undefined) - * @param inputsSchema - The normalized JSON Schema inputs definition - * @returns The inputs with defaults applied - */ -export function applyInputDefaults( - inputs: Record | undefined, - inputsSchema: ReturnType -): Record | undefined { - if (!inputsSchema?.properties) { - return inputs; - } - - const result: Record = { ...(inputs || {}) }; - let hasAnyDefaults = false; - - for (const [propertyName, propertySchema] of Object.entries(inputsSchema.properties)) { - const jsonSchema = propertySchema as JSONSchema7; - const currentValue = result[propertyName]; - - // Apply defaults if value is missing or undefined - if (currentValue === undefined) { - const defaultValue = applyDefaultFromSchema(jsonSchema, undefined, inputsSchema); - if (defaultValue !== undefined) { - result[propertyName] = defaultValue; - hasAnyDefaults = true; - } - } else if ( - jsonSchema.type === 'object' && - jsonSchema.properties && - typeof currentValue === 'object' && - !Array.isArray(currentValue) - ) { - // Recursively apply defaults to nested objects - const updatedValue = applyDefaultFromSchema(jsonSchema, currentValue, inputsSchema); - if (updatedValue !== undefined) { - result[propertyName] = updatedValue; - hasAnyDefaults = true; - } - } else if (jsonSchema.$ref) { - // Handle $ref: resolve and apply defaults - const defaultValue = applyDefaultFromSchema(jsonSchema, currentValue, inputsSchema); - if (defaultValue !== undefined) { - result[propertyName] = defaultValue; - hasAnyDefaults = true; - } - } - } - - // Return undefined if no defaults were applied and inputs was undefined - // This allows the caller to distinguish between "no defaults" and "empty object with defaults" - if (!hasAnyDefaults && inputs === undefined && Object.keys(result).length === 0) { - return undefined; - } - - return result; +export function normalizeInputsToJsonSchema(inputs?: unknown): JsonModelSchemaType | undefined { + return normalizeFieldsToJsonSchema(inputs); } diff --git a/src/platform/packages/shared/kbn-workflows/spec/schema.security_workflow_example.test.ts b/src/platform/packages/shared/kbn-workflows/spec/schema.security_workflow_example.test.ts index ee8fe8506ba2a..21515da602849 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/schema.security_workflow_example.test.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/schema.security_workflow_example.test.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { normalizeInputsToJsonSchema } from './lib/input_conversion'; +import { type NormalizableFieldSchema, normalizeFieldsToJsonSchema } from './lib/field_conversion'; import { WorkflowSchema } from './schema'; /** @@ -282,7 +282,9 @@ describe('Security Workflow Example - JSON Schema Showcase', () => { ).toBe(100); // Test 3: Verify normalization works - const normalizedInputs = normalizeInputsToJsonSchema(parsedWorkflow.inputs); + const normalizedInputs = normalizeFieldsToJsonSchema( + parsedWorkflow.inputs as NormalizableFieldSchema + ); expect(normalizedInputs).toBeDefined(); expect(normalizedInputs?.properties?.analyst.properties?.email.format).toBe('email'); }); diff --git a/src/platform/packages/shared/kbn-workflows/spec/schema.test.ts b/src/platform/packages/shared/kbn-workflows/spec/schema.test.ts index 9c4806d4a2fc9..bca659e38a39f 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/schema.test.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/schema.test.ts @@ -13,6 +13,7 @@ import { CollisionStrategySchema, ConcurrencySettingsSchema, EventTimestampSchema, + WorkflowOutputStepSchema, WorkflowSchema, WorkflowSchemaForAutocomplete, WorkflowSettingsSchema, @@ -144,6 +145,212 @@ describe('WorkflowSchemaForAutocomplete', () => { }); }); +describe('WorkflowOutputStepSchema', () => { + it('should validate a basic workflow.output step', () => { + const result = WorkflowOutputStepSchema.safeParse({ + name: 'emit_output', + type: 'workflow.output', + with: { + result: 'success', + count: 42, + }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ + name: 'emit_output', + type: 'workflow.output', + status: 'completed', // default status + with: { + result: 'success', + count: 42, + }, + }); + } + }); + + it('should apply default status of "completed"', () => { + const result = WorkflowOutputStepSchema.safeParse({ + name: 'emit_output', + type: 'workflow.output', + with: { data: 'test' }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.status).toBe('completed'); + } + }); + + it('should accept status: completed', () => { + const result = WorkflowOutputStepSchema.safeParse({ + name: 'emit_output', + type: 'workflow.output', + status: 'completed', + with: { data: 'test' }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.status).toBe('completed'); + } + }); + + it('should accept status: cancelled', () => { + const result = WorkflowOutputStepSchema.safeParse({ + name: 'emit_output', + type: 'workflow.output', + status: 'cancelled', + with: { data: 'test' }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.status).toBe('cancelled'); + } + }); + + it('should accept status: failed', () => { + const result = WorkflowOutputStepSchema.safeParse({ + name: 'emit_output', + type: 'workflow.output', + status: 'failed', + with: { error: 'Something went wrong' }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.status).toBe('failed'); + } + }); + + it('should reject invalid status values', () => { + const result = WorkflowOutputStepSchema.safeParse({ + name: 'emit_output', + type: 'workflow.output', + status: 'pending', // invalid + with: { data: 'test' }, + }); + + expect(result.success).toBe(false); + }); + + it('should accept complex output values', () => { + const result = WorkflowOutputStepSchema.safeParse({ + name: 'emit_output', + type: 'workflow.output', + with: { + stringField: 'test', + numberField: 123, + booleanField: true, + arrayField: [1, 2, 3], + objectField: { nested: 'value' }, + expressionField: '{{ steps.previous.output }}', + }, + }); + + expect(result.success).toBe(true); + }); + + it('should support if conditions', () => { + const result = WorkflowOutputStepSchema.safeParse({ + name: 'conditional_output', + type: 'workflow.output', + if: '{{ steps.check.output.shouldEmit }}', + with: { result: 'success' }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.if).toBe('{{ steps.check.output.shouldEmit }}'); + } + }); + + it('should require name field', () => { + const result = WorkflowOutputStepSchema.safeParse({ + type: 'workflow.output', + with: { data: 'test' }, + }); + + expect(result.success).toBe(false); + }); + + it('should require with field', () => { + const result = WorkflowOutputStepSchema.safeParse({ + name: 'emit_output', + type: 'workflow.output', + }); + + expect(result.success).toBe(false); + }); +}); + +describe('WorkflowSchema with workflow.output', () => { + it('should accept a workflow with workflow.output step', () => { + const result = WorkflowSchema.safeParse({ + name: 'test-workflow', + triggers: [{ type: 'manual' }], + outputs: [ + { name: 'result', type: 'string', required: true }, + { name: 'count', type: 'number', required: true }, + ], + steps: [ + { + name: 'process', + type: 'http', + with: { url: 'https://api.example.com' }, + }, + { + name: 'emit_result', + type: 'workflow.output', + status: 'completed', + with: { + result: '{{ steps.process.output.data }}', + count: 42, + }, + }, + ], + }); + + expect(result.success).toBe(true); + }); + + it('should accept workflow.output as the only step', () => { + const result = WorkflowSchema.safeParse({ + name: 'test-workflow', + triggers: [{ type: 'manual' }], + outputs: [{ name: 'message', type: 'string' }], + steps: [ + { + name: 'emit_immediately', + type: 'workflow.output', + with: { message: 'Hello, World!' }, + }, + ], + }); + + expect(result.success).toBe(true); + }); + + it('should accept workflow with outputs but no workflow.output step', () => { + const result = WorkflowSchema.safeParse({ + name: 'test-workflow', + triggers: [{ type: 'manual' }], + outputs: [{ name: 'result', type: 'string' }], + steps: [ + { + name: 'process', + type: 'http', + with: { url: 'https://api.example.com' }, + }, + ], + }); + + expect(result.success).toBe(true); + }); +}); + describe('ConcurrencySettingsSchema', () => { describe('key', () => { it('should accept valid key string', () => { diff --git a/src/platform/packages/shared/kbn-workflows/spec/schema.ts b/src/platform/packages/shared/kbn-workflows/spec/schema.ts index 30e540c06144c..a7bee92148a63 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/schema.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/schema.ts @@ -8,7 +8,7 @@ */ import { z } from '@kbn/zod/v4'; -import { convertLegacyInputsToJsonSchema } from './lib/input_conversion'; +import { convertLegacyFieldsToJsonSchema } from './lib/field_conversion'; import { JsonModelSchema } from './schema/common/json_model_schema'; import { timezoneNames } from './schema/triggers/timezone_names'; @@ -624,6 +624,24 @@ export const WorkflowExecuteAsyncStepOutputSchema = z.object({ startedAt: z.string().optional(), }); +export const WorkflowOutputStepSchema = BaseStepSchema.extend({ + type: z.literal('workflow.output'), + status: z.enum(['completed', 'cancelled', 'failed']).optional().default('completed'), + with: z.record(z.string(), z.any()), +}).extend(StepWithIfConditionSchema.shape); +export type WorkflowOutputStep = z.infer; + +export const WorkflowFailStepSchema = BaseStepSchema.extend({ + type: z.literal('workflow.fail'), + with: z + .object({ + message: z.string().optional(), + reason: z.string().optional(), + }) + .optional(), +}).extend(StepWithIfConditionSchema.shape); +export type WorkflowFailStep = z.infer; + /* --- Inputs --- */ export const WorkflowInputTypeEnum = z.enum(['string', 'number', 'boolean', 'choice', 'array']); @@ -671,6 +689,11 @@ export const WorkflowInputSchema = z.union([ ]); export type LegacyWorkflowInput = z.infer; +/* --- Outputs --- */ +// Outputs use the same format as inputs (name, type, required, etc.); default is ignored at runtime for outputs. +export const WorkflowOutputSchema = WorkflowInputSchema; +export type WorkflowOutput = z.infer; + /* --- Consts --- */ export const WorkflowConstsSchema = z.record( z.string(), @@ -698,6 +721,8 @@ const StepSchema = z.lazy(() => MergeStepSchema, WorkflowExecuteStepSchema, WorkflowExecuteAsyncStepSchema, + WorkflowOutputStepSchema, + WorkflowFailStepSchema, LoopBreakStepSchema, LoopContinueStepSchema, BaseConnectorStepSchema, @@ -720,6 +745,8 @@ export const BuiltInStepTypes = [ WaitStepSchema.shape.type.value, WorkflowExecuteStepSchema.shape.type.value, WorkflowExecuteAsyncStepSchema.shape.type.value, + WorkflowOutputStepSchema.shape.type.value, + WorkflowFailStepSchema.shape.type.value, LoopBreakStepSchema.shape.type.value, LoopContinueStepSchema.shape.type.value, ]; @@ -742,34 +769,34 @@ const WorkflowSchemaBase = z.object({ z.array(WorkflowInputSchema), ]) .optional(), + outputs: z.union([JsonModelSchema, z.array(WorkflowOutputSchema)]).optional(), consts: WorkflowConstsSchema.optional(), steps: z.array(StepSchema).min(1), }); +/** Normalize inputs or outputs from either JSON Schema or legacy array format to JsonModelSchema. */ +function normalizeFieldsToJsonSchema(value: unknown): z.infer | undefined { + if (!value) return undefined; + if (typeof value === 'object' && !Array.isArray(value) && 'properties' in value) { + return value as z.infer; + } + if (Array.isArray(value)) { + return convertLegacyFieldsToJsonSchema(value); + } + return undefined; +} + export const WorkflowSchema = WorkflowSchemaBase.extend({ triggers: z.array(TriggerSchema).min(1), }).transform((data) => { - // Transform inputs from legacy array format to JSON Schema format - let normalizedInputs: z.infer | undefined; - if (data.inputs) { - if ( - 'properties' in data.inputs && - typeof data.inputs === 'object' && - !Array.isArray(data.inputs) - ) { - normalizedInputs = data.inputs as z.infer; - } else if (Array.isArray(data.inputs)) { - normalizedInputs = convertLegacyInputsToJsonSchema(data.inputs); - } - } + const normalizedInputs = normalizeFieldsToJsonSchema(data.inputs); + const normalizedOutputs = normalizeFieldsToJsonSchema(data.outputs); - // Return the data with normalized inputs, preserving all other fields as-is - // This preserves the optionality of fields since we're not explicitly listing them all - // Exclude inputs from spread to ensure it's always the normalized JSON Schema format (or undefined) - const { inputs: _, ...rest } = data; + const { inputs: _, outputs: __, ...rest } = data; return { ...rest, ...(normalizedInputs !== undefined && { inputs: normalizedInputs }), + ...(normalizedOutputs !== undefined && { outputs: normalizedOutputs }), }; }); @@ -810,6 +837,20 @@ const WorkflowSchemaForAutocompleteBase = z ]) .optional() .catch(undefined), + outputs: z + .union([ + JsonModelSchema, + z.array( + z + .object({ + name: z.string().catch(''), + type: z.string().catch(''), + }) + .passthrough() + ), + ]) + .optional() + .catch(undefined), consts: WorkflowConstsSchema.optional(), steps: z .array( @@ -924,6 +965,17 @@ export const WorkflowContextSchema = z.object({ workflow: WorkflowDataContextSchema, kibanaUrl: z.string(), inputs: z.record(z.string(), WorkflowInputValueSchema).optional(), + output: z + .record( + z.string(), + z.union([ + z.string(), + z.number(), + z.boolean(), + z.union([z.array(z.string()), z.array(z.number()), z.array(z.boolean())]), + ]) + ) + .optional(), consts: z.record(z.string(), z.any()).optional(), now: z.date().optional(), parent: z @@ -938,8 +990,9 @@ export type WorkflowContext = z.infer; export const DynamicWorkflowContextSchema = WorkflowContextSchema.extend({ // overriding record with object to avoid type mismatch when - // extending with actual inputs and consts of different types + // extending with actual inputs, outputs and consts of different types inputs: z.object({}), + output: z.object({}), consts: z.object({}), // overriding event with base event schema (spaceId only) so it can be // dynamically extended with trigger-specific properties (e.g., alerts, rule) diff --git a/src/platform/packages/shared/kbn-workflows/spec/schema.workflow_json_schema.test.ts b/src/platform/packages/shared/kbn-workflows/spec/schema.workflow_json_schema.test.ts index 7c7d80962b1a7..1d5ef349d3de0 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/schema.workflow_json_schema.test.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/schema.workflow_json_schema.test.ts @@ -12,7 +12,11 @@ import { readFileSync } from 'fs'; import { join } from 'path'; import { parse } from 'yaml'; -import { applyInputDefaults, normalizeInputsToJsonSchema } from './lib/input_conversion'; +import { + applyInputDefaults, + type NormalizableFieldSchema, + normalizeFieldsToJsonSchema, +} from './lib/field_conversion'; import { WorkflowSchema } from './schema'; // Note: getWorkflowContextSchema is in the plugin, not in the package // For this test, we'll test the schema parsing and normalization directly @@ -195,7 +199,9 @@ describe('Workflow with JSON Schema Inputs - Comprehensive Features', () => { expect(parsedWorkflow.inputs?.required).toContain('customer'); // Test 3: Normalize inputs (should return as-is since already in new format) - const normalizedInputs = normalizeInputsToJsonSchema(parsedWorkflow.inputs); + const normalizedInputs = normalizeFieldsToJsonSchema( + parsedWorkflow.inputs as NormalizableFieldSchema + ); expect(normalizedInputs).toBeDefined(); expect(normalizedInputs?.properties).toBeDefined(); @@ -331,7 +337,7 @@ describe('Workflow with JSON Schema Inputs - Comprehensive Features', () => { expect(parsedWorkflow.inputs?.additionalProperties).toBe(false); // Verify normalization preserves the structure - const normalizedInputs = normalizeInputsToJsonSchema(parsedWorkflow.inputs); + const normalizedInputs = normalizeFieldsToJsonSchema(parsedWorkflow.inputs); expect(normalizedInputs?.properties?.customer.properties?.email.format).toBe('email'); expect(normalizedInputs?.properties?.customer.additionalProperties).toBe(false); }); @@ -374,7 +380,7 @@ describe('Workflow with JSON Schema Inputs - Comprehensive Features', () => { expect(parsedWorkflow.inputs?.definitions?.UserSchema.properties?.age.default).toBe(30); // Test 5: Normalize inputs (should preserve $ref and definitions) - const normalizedInputs = normalizeInputsToJsonSchema(parsedWorkflow.inputs); + const normalizedInputs = normalizeFieldsToJsonSchema(parsedWorkflow.inputs); expect(normalizedInputs).toBeDefined(); expect(normalizedInputs?.properties?.user.$ref).toBe('#/definitions/UserSchema'); expect(normalizedInputs?.definitions?.UserSchema).toBeDefined(); diff --git a/src/platform/plugins/shared/workflows_execution_engine/integration_tests/tests/workflow_fail.test.ts b/src/platform/plugins/shared/workflows_execution_engine/integration_tests/tests/workflow_fail.test.ts new file mode 100644 index 0000000000000..4c7ebd1e58390 --- /dev/null +++ b/src/platform/plugins/shared/workflows_execution_engine/integration_tests/tests/workflow_fail.test.ts @@ -0,0 +1,295 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { ExecutionStatus } from '@kbn/workflows'; +import { FakeConnectors } from '../mocks/actions_plugin.mock'; +import { WorkflowRunFixture } from '../workflow_run_fixture'; + +describe('workflow.fail step', () => { + let workflowRunFixture: WorkflowRunFixture; + + beforeEach(async () => { + workflowRunFixture = new WorkflowRunFixture(); + }); + + describe('basic functionality', () => { + it('should fail workflow with error message', async () => { + await workflowRunFixture.runWorkflow({ + workflowYaml: ` +steps: + - name: fail_step + type: workflow.fail + with: + message: "Workflow failed due to validation error" + `, + }); + + const workflowExecution = + workflowRunFixture.workflowExecutionRepositoryMock.workflowExecutions.get( + 'fake_workflow_execution_id' + ); + + expect(workflowExecution?.status).toBe(ExecutionStatus.FAILED); + expect(workflowExecution?.error).toBeDefined(); + expect(workflowExecution?.error?.message).toBe('Workflow failed due to validation error'); + expect(workflowExecution?.context?.output).toEqual({ + message: 'Workflow failed due to validation error', + }); + }); + + it('should store message in context.output for external access', async () => { + await workflowRunFixture.runWorkflow({ + workflowYaml: ` +steps: + - name: terminate + type: workflow.fail + with: + message: "Critical error occurred" + `, + }); + + const workflowExecution = + workflowRunFixture.workflowExecutionRepositoryMock.workflowExecutions.get( + 'fake_workflow_execution_id' + ); + + expect(workflowExecution?.context?.output).toMatchObject({ + message: 'Critical error occurred', + }); + }); + + it('should record step execution with workflow.output type', async () => { + await workflowRunFixture.runWorkflow({ + workflowYaml: ` +steps: + - name: fail_step + type: workflow.fail + with: + message: "Test failure" + `, + }); + + const stepExecutions = Array.from( + workflowRunFixture.stepExecutionRepositoryMock.stepExecutions.values() + ); + + const failStep = stepExecutions.find((se) => se.stepId === 'fail_step'); + + expect(failStep).toBeDefined(); + expect(failStep?.status).toBe(ExecutionStatus.COMPLETED); + // Step type should be workflow.output (internal transformation) + expect(failStep?.stepType).toBe('workflow.output'); + expect(failStep?.output).toEqual({ + message: 'Test failure', + }); + }); + }); + + describe('conditional failure', () => { + it('should fail workflow when condition is true', async () => { + await workflowRunFixture.runWorkflow({ + workflowYaml: ` +inputs: + - name: amount + type: number +steps: + - name: validate_amount + type: workflow.fail + if: "\${{ inputs.amount < 0 }}" + with: + message: "Amount cannot be negative: {{inputs.amount}}" + + - name: process + type: slack + connector-id: ${FakeConnectors.slack1.name} + with: + message: "Processing amount: {{inputs.amount}}" + `, + inputs: { amount: -10 }, + }); + + const workflowExecution = + workflowRunFixture.workflowExecutionRepositoryMock.workflowExecutions.get( + 'fake_workflow_execution_id' + ); + + expect(workflowExecution?.status).toBe(ExecutionStatus.FAILED); + expect(workflowExecution?.error?.message).toBe('Amount cannot be negative: -10'); + + // Verify the process step was never executed + const stepExecutions = Array.from( + workflowRunFixture.stepExecutionRepositoryMock.stepExecutions.values() + ); + const processStep = stepExecutions.find((se) => se.stepId === 'process'); + expect(processStep).toBeUndefined(); + }); + + it('should skip workflow.fail when condition is false', async () => { + await workflowRunFixture.runWorkflow({ + workflowYaml: ` +inputs: + - name: amount + type: number +steps: + - name: validate_amount + type: workflow.fail + if: "\${{ inputs.amount < 0 }}" + with: + message: "Amount cannot be negative" + + - name: process + type: slack + connector-id: ${FakeConnectors.slack1.name} + with: + message: "Processing amount: {{inputs.amount}}" + `, + inputs: { amount: 100 }, + }); + + const workflowExecution = + workflowRunFixture.workflowExecutionRepositoryMock.workflowExecutions.get( + 'fake_workflow_execution_id' + ); + + expect(workflowExecution?.status).toBe(ExecutionStatus.COMPLETED); + + // Verify the process step was executed + const stepExecutions = Array.from( + workflowRunFixture.stepExecutionRepositoryMock.stepExecutions.values() + ); + const processStep = stepExecutions.find((se) => se.stepId === 'process'); + expect(processStep).toBeDefined(); + expect(processStep?.status).toBe(ExecutionStatus.COMPLETED); + }); + }); + + describe('message templating', () => { + it('should support dynamic message with template expressions', async () => { + await workflowRunFixture.runWorkflow({ + workflowYaml: ` +inputs: + - name: username + type: string + - name: role + type: string +steps: + - name: check_permission + type: workflow.fail + with: + message: "User '{{inputs.username}}' with role '{{inputs.role}}' is not authorized" + `, + inputs: { username: 'john_doe', role: 'viewer' }, + }); + + const workflowExecution = + workflowRunFixture.workflowExecutionRepositoryMock.workflowExecutions.get( + 'fake_workflow_execution_id' + ); + + expect(workflowExecution?.status).toBe(ExecutionStatus.FAILED); + expect(workflowExecution?.error?.message).toBe( + "User 'john_doe' with role 'viewer' is not authorized" + ); + }); + + it('should support message with step output references', async () => { + await workflowRunFixture.runWorkflow({ + workflowYaml: ` +steps: + - name: check_value + type: slack + connector-id: ${FakeConnectors.slack1.name} + with: + message: "Checking" + + - name: fail_with_context + type: workflow.fail + with: + message: "Step check_value failed with status: {{steps.check_value.output.text}}" + `, + }); + + const workflowExecution = + workflowRunFixture.workflowExecutionRepositoryMock.workflowExecutions.get( + 'fake_workflow_execution_id' + ); + + expect(workflowExecution?.status).toBe(ExecutionStatus.FAILED); + expect(workflowExecution?.error?.message).toContain('Step check_value failed'); + }); + }); + + describe('execution order', () => { + it('should stop workflow execution immediately', async () => { + await workflowRunFixture.runWorkflow({ + workflowYaml: ` +steps: + - name: first_step + type: slack + connector-id: ${FakeConnectors.slack1.name} + with: + message: "First" + + - name: fail_step + type: workflow.fail + with: + message: "Stopping workflow" + + - name: should_not_run + type: slack + connector-id: ${FakeConnectors.slack2.name} + with: + message: "Should not execute" + `, + }); + + const workflowExecution = + workflowRunFixture.workflowExecutionRepositoryMock.workflowExecutions.get( + 'fake_workflow_execution_id' + ); + + expect(workflowExecution?.status).toBe(ExecutionStatus.FAILED); + + const stepExecutions = Array.from( + workflowRunFixture.stepExecutionRepositoryMock.stepExecutions.values() + ); + expect(stepExecutions).toHaveLength(2); // first_step + fail_step + + const firstStep = stepExecutions.find((se) => se.stepId === 'first_step'); + const failStep = stepExecutions.find((se) => se.stepId === 'fail_step'); + const shouldNotRun = stepExecutions.find((se) => se.stepId === 'should_not_run'); + + expect(firstStep?.status).toBe(ExecutionStatus.COMPLETED); + expect(failStep?.status).toBe(ExecutionStatus.COMPLETED); + expect(shouldNotRun).toBeUndefined(); // Should not have executed + }); + }); + + describe('error handling', () => { + it('should handle template expression errors gracefully', async () => { + await workflowRunFixture.runWorkflow({ + workflowYaml: ` +steps: + - name: fail_with_invalid_expression + type: workflow.fail + with: + message: "Error: {{steps.nonexistent.output}}" + `, + }); + + const workflowExecution = + workflowRunFixture.workflowExecutionRepositoryMock.workflowExecutions.get( + 'fake_workflow_execution_id' + ); + + // Should still fail, even if template evaluation has issues + expect(workflowExecution?.status).toBe(ExecutionStatus.FAILED); + }); + }); +}); diff --git a/src/platform/plugins/shared/workflows_execution_engine/integration_tests/tests/workflow_output.test.ts b/src/platform/plugins/shared/workflows_execution_engine/integration_tests/tests/workflow_output.test.ts new file mode 100644 index 0000000000000..308ee07b685d4 --- /dev/null +++ b/src/platform/plugins/shared/workflows_execution_engine/integration_tests/tests/workflow_output.test.ts @@ -0,0 +1,244 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { ExecutionStatus } from '@kbn/workflows'; +import { FakeConnectors } from '../mocks/actions_plugin.mock'; +import { WorkflowRunFixture } from '../workflow_run_fixture'; + +describe('workflow.output step', () => { + let workflowRunFixture: WorkflowRunFixture; + + beforeEach(async () => { + workflowRunFixture = new WorkflowRunFixture(); + }); + + describe('basic output emission', () => { + it('should emit declared outputs and complete workflow', async () => { + await workflowRunFixture.runWorkflow({ + workflowYaml: ` +outputs: + - name: result + type: string + - name: count + type: number +steps: + - name: emit + type: workflow.output + with: + result: "success" + count: 42 + `, + }); + + const workflowExecution = + workflowRunFixture.workflowExecutionRepositoryMock.workflowExecutions.get( + 'fake_workflow_execution_id' + ); + + expect(workflowExecution?.status).toBe(ExecutionStatus.COMPLETED); + expect(workflowExecution?.context?.output).toEqual({ + result: 'success', + count: 42, + }); + }); + + it('should record step execution with workflow.output type', async () => { + await workflowRunFixture.runWorkflow({ + workflowYaml: ` +outputs: + - name: result + type: string +steps: + - name: out_step + type: workflow.output + with: + result: "done" + `, + }); + + const stepExecutions = Array.from( + workflowRunFixture.stepExecutionRepositoryMock.stepExecutions.values() + ); + const outStep = stepExecutions.find((se) => se.stepId === 'out_step'); + + expect(outStep).toBeDefined(); + expect(outStep?.status).toBe(ExecutionStatus.COMPLETED); + expect(outStep?.stepType).toBe('workflow.output'); + expect(outStep?.output).toEqual({ result: 'done' }); + }); + }); + + describe('early termination', () => { + it('should stop workflow execution immediately after workflow.output', async () => { + await workflowRunFixture.runWorkflow({ + workflowYaml: ` +outputs: + - name: value + type: string +steps: + - name: first_step + type: slack + connector-id: ${FakeConnectors.slack1.name} + with: + message: "First" + + - name: out_step + type: workflow.output + with: + value: "early exit" + + - name: should_not_run + type: slack + connector-id: ${FakeConnectors.slack2.name} + with: + message: "Should not execute" + `, + }); + + const workflowExecution = + workflowRunFixture.workflowExecutionRepositoryMock.workflowExecutions.get( + 'fake_workflow_execution_id' + ); + + expect(workflowExecution?.status).toBe(ExecutionStatus.COMPLETED); + expect(workflowExecution?.context?.output).toEqual({ value: 'early exit' }); + + const stepExecutions = Array.from( + workflowRunFixture.stepExecutionRepositoryMock.stepExecutions.values() + ); + expect(stepExecutions).toHaveLength(2); // first_step + out_step + + const firstStep = stepExecutions.find((se) => se.stepId === 'first_step'); + const outStep = stepExecutions.find((se) => se.stepId === 'out_step'); + const shouldNotRun = stepExecutions.find((se) => se.stepId === 'should_not_run'); + + expect(firstStep?.status).toBe(ExecutionStatus.COMPLETED); + expect(outStep?.status).toBe(ExecutionStatus.COMPLETED); + expect(shouldNotRun).toBeUndefined(); + }); + }); + + describe('template expressions in output values', () => { + it('should support step output references in workflow.output', async () => { + await workflowRunFixture.runWorkflow({ + workflowYaml: ` +outputs: + - name: summary + type: string + - name: total + type: number +inputs: + - name: x + type: number + - name: y + type: number +steps: + - name: calc + type: data.set + with: + count: "\${{ inputs.x | plus: inputs.y }}" + + - name: emit + type: workflow.output + with: + summary: "Sum is {{ steps.calc.output.count }}" + total: "\${{ steps.calc.output.count }}" + `, + inputs: { x: 3, y: 7 }, + }); + + const workflowExecution = + workflowRunFixture.workflowExecutionRepositoryMock.workflowExecutions.get( + 'fake_workflow_execution_id' + ); + + expect(workflowExecution?.status).toBe(ExecutionStatus.COMPLETED); + expect(workflowExecution?.context?.output).toMatchObject({ + summary: 'Sum is 10', + total: 10, + }); + }); + }); + + describe('workflow.output inside if block', () => { + it('should emit output when if branch is taken', async () => { + await workflowRunFixture.runWorkflow({ + workflowYaml: ` +outputs: + - name: result + type: string +inputs: + - name: fail + type: boolean + default: false +steps: + - name: branch + type: if + condition: "\${{ inputs.fail }}" + steps: + - name: fail_out + type: workflow.output + status: failed + with: + message: "Ooops" + else: + - name: success_out + type: workflow.output + with: + result: "success" + `, + inputs: { fail: false }, + }); + + const workflowExecution = + workflowRunFixture.workflowExecutionRepositoryMock.workflowExecutions.get( + 'fake_workflow_execution_id' + ); + + expect(workflowExecution?.status).toBe(ExecutionStatus.COMPLETED); + expect(workflowExecution?.context?.output).toEqual({ result: 'success' }); + }); + + it('should use output from else branch when condition is false', async () => { + await workflowRunFixture.runWorkflow({ + workflowYaml: ` +outputs: + - name: path + type: string +inputs: + - name: useElse + type: boolean +steps: + - name: branch + type: if + condition: "\${{ inputs.useElse }}" + steps: + - name: then_out + type: workflow.output + with: + path: "then" + else: + - name: else_out + type: workflow.output + with: + path: "else" + `, + inputs: { useElse: false }, + }); + + const workflowExecution = + workflowRunFixture.workflowExecutionRepositoryMock.workflowExecutions.get( + 'fake_workflow_execution_id' + ); + + expect(workflowExecution?.status).toBe(ExecutionStatus.COMPLETED); + expect(workflowExecution?.context?.output).toEqual({ path: 'else' }); + }); + }); +}); diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/step/nodes_factory.ts b/src/platform/plugins/shared/workflows_execution_engine/server/step/nodes_factory.ts index 44f922ae88d36..79805213fa9d7 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/step/nodes_factory.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/step/nodes_factory.ts @@ -28,6 +28,7 @@ import type { WorkflowExecuteAsyncGraphNode, WorkflowExecuteGraphNode, WorkflowGraph, + WorkflowOutputGraphNode, } from '@kbn/workflows/graph'; import { isDataSet, @@ -70,6 +71,7 @@ import { WaitForInputStepImpl } from './wait_for_input_step/wait_for_input_step' import { WaitStepImpl } from './wait_step/wait_step'; import { EnterWhileNodeImpl, ExitWhileNodeImpl } from './while_step'; import { WorkflowExecuteStepImpl } from './workflow_execute_step/workflow_execute_step_impl'; +import { WorkflowOutputStepImpl } from './workflow_output_step/workflow_output_step_impl'; import type { ConnectorExecutor } from '../connector_executor'; import type { StepExecutionRuntime } from '../workflow_context_manager/step_execution_runtime'; import type { StepExecutionRuntimeFactory } from '../workflow_context_manager/step_execution_runtime_factory'; @@ -347,6 +349,18 @@ export class NodesFactory { stepExecutionRepository: this.dependencies.stepExecutionRepository, workflowLogger: this.workflowLogger, }); + case 'workflow.output': + this.workflowLogger.logDebug(`Creating workflow.output step`, { + event: { action: 'workflow-output-step-creation', outcome: 'success' }, + tags: ['step-factory', 'workflow-output', 'core-step'], + }); + return new WorkflowOutputStepImpl( + node as WorkflowOutputGraphNode, + stepExecutionRuntime, + this.workflowRuntime, + stepLogger, + this.stepExecutionRuntimeFactory + ); default: throw new Error(`Unknown node type: ${node.stepType}`); } diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/step/workflow_output_step/workflow_output_step_impl.ts b/src/platform/plugins/shared/workflows_execution_engine/server/step/workflow_output_step/workflow_output_step_impl.ts new file mode 100644 index 0000000000000..6b267ee3dea37 --- /dev/null +++ b/src/platform/plugins/shared/workflows_execution_engine/server/step/workflow_output_step/workflow_output_step_impl.ts @@ -0,0 +1,205 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { WorkflowOutputStep } from '@kbn/workflows'; +import { ExecutionStatus } from '@kbn/workflows'; +import type { WorkflowOutputGraphNode } from '@kbn/workflows/graph'; +import { buildFieldsZodValidator } from '@kbn/workflows/spec/lib/build_fields_zod_validator'; +import { normalizeFieldsToJsonSchema } from '@kbn/workflows/spec/lib/field_conversion'; +import type { StepExecutionRuntime } from '../../workflow_context_manager/step_execution_runtime'; +import type { StepExecutionRuntimeFactory } from '../../workflow_context_manager/step_execution_runtime_factory'; +import type { WorkflowExecutionRuntimeManager } from '../../workflow_context_manager/workflow_execution_runtime_manager'; +import type { IWorkflowEventLogger } from '../../workflow_event_logger'; +import type { NodeImplementation } from '../node_implementation'; + +/** + * Implements the workflow.output step which emits outputs and terminates workflow execution. + * + * When this step executes: + * 1. Validates the output values against the workflow's declared output schema (if any) + * 2. Sets the workflow outputs in the execution context + * 3. Terminates the workflow with the specified status (completed/cancelled/failed) + * 4. Prevents any subsequent steps from executing by clearing the next node + */ +export class WorkflowOutputStepImpl implements NodeImplementation { + constructor( + private node: WorkflowOutputGraphNode, + private stepExecutionRuntime: StepExecutionRuntime, + private workflowExecutionRuntime: WorkflowExecutionRuntimeManager, + private workflowLogger: IWorkflowEventLogger, + private stepExecutionRuntimeFactory: StepExecutionRuntimeFactory + ) {} + + /** + * Completes all ancestor steps in the scope stack. + * Uses the step's own finishStep() logic so lifecycle behaviour + * (logging, timing, status) is applied consistently. + */ + private completeAncestorSteps(): void { + let stack = this.stepExecutionRuntime.scopeStack; + while (!stack.isEmpty()) { + const currentScope = stack.getCurrentScope(); + stack = stack.exitScope(); + if (currentScope) { + const scopeStepRuntime = this.stepExecutionRuntimeFactory.createStepExecutionRuntime({ + nodeId: currentScope.nodeId, + stackFrames: stack.stackFrames, + }); + if (scopeStepRuntime.stepExecutionExists()) { + scopeStepRuntime.finishStep(); + } + } + } + } + + async run(): Promise { + this.stepExecutionRuntime.startStep(); + await this.stepExecutionRuntime.flushEventLogs(); + + const step = this.node.configuration as WorkflowOutputStep; + // Render template variables in the output values (with: may be omitted for workflow.fail) + const outputValues = step.with + ? (this.stepExecutionRuntime.contextManager.renderValueAccordingToContext( + step.with + ) as Record) + : {}; + + try { + // Get the workflow definition to check for declared outputs + const workflowExecution = this.workflowExecutionRuntime.getWorkflowExecution(); + const declaredOutputs = workflowExecution.workflowDefinition?.outputs; + + const normalizedOutputs = normalizeFieldsToJsonSchema(declaredOutputs); + + if (normalizedOutputs?.properties && Object.keys(normalizedOutputs.properties).length > 0) { + const validator = buildFieldsZodValidator(normalizedOutputs); + const validationResult = validator.safeParse(outputValues); + + if (!validationResult.success) { + const errorMessages = validationResult.error.issues + .map((issue) => { + const fieldName = (issue.path[0] as string) || ''; + return `${fieldName}: ${issue.message}`; + }) + .join(', '); + const errorMessage = `Output validation failed: ${errorMessages}`; + const validationError = new Error(errorMessage); + + this.workflowLogger.logError(errorMessage, validationError, { + event: { action: 'workflow-output-validation-failed', outcome: 'failure' }, + tags: ['workflow-output', 'validation-error'], + }); + + // Fail the step with validation error (failStep also sets workflow-level error via updateWorkflowExecution) + this.stepExecutionRuntime.failStep(validationError); + await this.stepExecutionRuntime.flushEventLogs(); + + this.workflowExecutionRuntime.setWorkflowStatus(ExecutionStatus.FAILED); + return; + } + } + + this.workflowLogger.logInfo('Workflow outputs emitted successfully', { + event: { action: 'workflow-output-emitted', outcome: 'success' }, + tags: ['workflow-output', 'success'], + }); + + // Execution status from step (default 'completed' is applied by WorkflowOutputStepSchema) + const stepStatus = step.status; + let executionStatus: ExecutionStatus; + let outcome: 'success' | 'failure' | 'unknown'; + + // Store outputs in workflow execution context and persist them + // This ensures outputs are saved before the workflow terminates + this.workflowExecutionRuntime.setWorkflowOutputs(outputValues); + + switch (stepStatus) { + case 'completed': + executionStatus = ExecutionStatus.COMPLETED; + outcome = 'success'; + // Complete the step successfully with the output values + this.stepExecutionRuntime.finishStep(outputValues); + break; + case 'cancelled': { + executionStatus = ExecutionStatus.CANCELLED; + outcome = 'unknown'; + // User can provide reason/message in with:; otherwise default mentions the step + const stepName = this.node.configuration?.name ?? 'workflow.output'; + const cancellationReason = + (typeof outputValues.reason === 'string' && outputValues.reason) || + (typeof outputValues.message === 'string' && outputValues.message) || + `Cancelled by step '${stepName}'`; + this.workflowExecutionRuntime.setWorkflowCancelled(cancellationReason); + this.stepExecutionRuntime.finishStep(outputValues); + break; + } + case 'failed': { + executionStatus = ExecutionStatus.FAILED; + outcome = 'failure'; + const errorMessage = + (typeof outputValues.message === 'string' && outputValues.message) || + (typeof outputValues.reason === 'string' && outputValues.reason) || + 'Workflow terminated with failed status'; + + const failureError = new Error(errorMessage); + + this.workflowLogger.logInfo(`Workflow failed with message: ${errorMessage}`, { + event: { action: 'workflow-output-error-set', outcome: 'failure' }, + tags: ['workflow-output', 'error'], + error: { + message: errorMessage, + type: failureError.name, + }, + }); + + // The step itself completes successfully (it did its job), + // but the workflow is marked as failed via setWorkflowError + this.stepExecutionRuntime.finishStep(outputValues); + this.workflowExecutionRuntime.setWorkflowError(failureError); + break; + } + default: + executionStatus = ExecutionStatus.COMPLETED; + outcome = 'success'; + this.stepExecutionRuntime.finishStep(outputValues); + } + + await this.stepExecutionRuntime.flushEventLogs(); + + this.workflowLogger.logInfo(`Workflow terminated with status: ${stepStatus}`, { + event: { + action: 'workflow-terminated', + outcome, + }, + tags: ['workflow-output', 'termination'], + }); + + this.completeAncestorSteps(); + + // Update the workflow execution status to terminate the workflow (cancelled already set via setWorkflowCancelled) + if (executionStatus !== ExecutionStatus.CANCELLED) { + this.workflowExecutionRuntime.setWorkflowStatus(executionStatus); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const errorObj = error instanceof Error ? error : new Error(errorMessage); + + this.workflowLogger.logError(`Workflow output step failed: ${errorMessage}`, errorObj, { + event: { action: 'workflow-output-failed', outcome: 'failure' }, + tags: ['workflow-output', 'error'], + }); + + // failStep() sets workflow-level error via updateWorkflowExecution({ error }) + this.stepExecutionRuntime.failStep(errorObj); + await this.stepExecutionRuntime.flushEventLogs(); + + this.workflowExecutionRuntime.setWorkflowStatus(ExecutionStatus.FAILED); + } + } +} diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/workflow_context_manager/build_workflow_context.ts b/src/platform/plugins/shared/workflows_execution_engine/server/workflow_context_manager/build_workflow_context.ts index dccb2c9deefe3..f1be0c507d658 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/workflow_context_manager/build_workflow_context.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/workflow_context_manager/build_workflow_context.ts @@ -11,8 +11,8 @@ import type { CoreStart } from '@kbn/core/server'; import type { EsWorkflowExecution, WorkflowContext } from '@kbn/workflows'; import { applyInputDefaults, - normalizeInputsToJsonSchema, -} from '@kbn/workflows/spec/lib/input_conversion'; + normalizeFieldsToJsonSchema, +} from '@kbn/workflows/spec/lib/field_conversion'; import type { ContextDependencies } from './types'; import { buildWorkflowExecutionUrl, getKibanaUrl } from '../utils'; @@ -28,7 +28,7 @@ export function buildWorkflowContext( workflowExecution.workflowId, workflowExecution.id ); - const normalizedInputsSchema = normalizeInputsToJsonSchema( + const normalizedInputsSchema = normalizeFieldsToJsonSchema( workflowExecution.workflowDefinition.inputs ); @@ -63,6 +63,7 @@ export function buildWorkflowContext( consts: workflowExecution.workflowDefinition?.consts ?? {}, event: workflowExecution.context?.event, inputs: inputsWithDefaults, + output: workflowExecution.context?.output, now: new Date(), parent: parentWorkflowId && parentWorkflowExecutionId diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/workflow_context_manager/workflow_execution_runtime_manager.ts b/src/platform/plugins/shared/workflows_execution_engine/server/workflow_context_manager/workflow_execution_runtime_manager.ts index 7c93d001825b1..b07788c9fbf67 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/workflow_context_manager/workflow_execution_runtime_manager.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/workflow_context_manager/workflow_execution_runtime_manager.ts @@ -216,6 +216,33 @@ export class WorkflowExecutionRuntimeManager { }); } + public setWorkflowOutputs(outputs: Record): void { + this.workflowExecutionState.updateWorkflowExecution({ + context: { + ...(this.workflowExecution.context || {}), + output: outputs, + }, + }); + } + + public setWorkflowStatus(status: ExecutionStatus): void { + this.workflowExecutionState.updateWorkflowExecution({ status }); + } + + /** + * Sets workflow status to CANCELLED with a reason (and cancelledAt, cancelledBy). + * Use when workflow.output has status: 'cancelled' or when cancelling with a specific message. + */ + public setWorkflowCancelled(reason: string): void { + const cancelledAt = new Date().toISOString(); + this.workflowExecutionState.updateWorkflowExecution({ + status: ExecutionStatus.CANCELLED, + cancellationReason: reason, + cancelledAt, + cancelledBy: 'workflow', + }); + } + /** * Pops scopes from the scope stack, finishing each one, until {@link shouldStop} * returns true for the current scope (or the stack is exhausted when no predicate diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/workflow_execution_loop/catch_error.ts b/src/platform/plugins/shared/workflows_execution_engine/server/workflow_execution_loop/catch_error.ts index 4f939414da37a..0566d2f43989f 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/workflow_execution_loop/catch_error.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/workflow_execution_loop/catch_error.ts @@ -7,6 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { ExecutionStatus } from '@kbn/workflows'; import { ExecutionError } from '@kbn/workflows/server'; import type { WorkflowExecutionLoopParams } from './types'; import type { NodeWithErrorCatching } from '../step/node_implementation'; @@ -71,10 +72,15 @@ export async function catchError( } if (failedStepExecutionRuntime.stepExecutionExists()) { - failedStepExecutionRuntime.failStep( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - new ExecutionError(params.workflowExecutionState.getWorkflowExecution().error!) - ); + const stepExecution = failedStepExecutionRuntime.stepExecution; + // A step may already be COMPLETED if workflow.output/workflow.fail finished + // it successfully before setting the workflow-level error (e.g., status: 'failed') + if (stepExecution?.status !== ExecutionStatus.COMPLETED) { + failedStepExecutionRuntime.failStep( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + new ExecutionError(params.workflowExecutionState.getWorkflowExecution().error!) + ); + } } while ( diff --git a/src/platform/plugins/shared/workflows_management/common/lib/yaml/parse_workflow_yaml_for_autocomplete.test.ts b/src/platform/plugins/shared/workflows_management/common/lib/yaml/parse_workflow_yaml_for_autocomplete.test.ts index 6cf02a608151e..4b0e86801e491 100644 --- a/src/platform/plugins/shared/workflows_management/common/lib/yaml/parse_workflow_yaml_for_autocomplete.test.ts +++ b/src/platform/plugins/shared/workflows_management/common/lib/yaml/parse_workflow_yaml_for_autocomplete.test.ts @@ -152,4 +152,36 @@ steps: } }); }); + + describe('JSON Schema format outputs', () => { + it('should preserve outputs in JSON Schema format', () => { + const yaml = `name: Test +triggers: + - type: manual +outputs: + required: + - b + properties: + a: + type: string + default: "aaaa" + b: + type: number +steps: + - name: return + type: workflow.output + with: + a: hello`; + + const result = parseWorkflowYamlForAutocomplete(yaml); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.outputs).toBeDefined(); + const outputs = result.data.outputs as Record; + expect(outputs.properties).toBeDefined(); + expect(outputs.required).toEqual(['b']); + } + }); + }); }); diff --git a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/load_workflows_thunk.ts b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/load_workflows_thunk.ts index 4a1046f6a76d3..6275306471a75 100644 --- a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/load_workflows_thunk.ts +++ b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/load_workflows_thunk.ts @@ -8,7 +8,7 @@ */ import { createAsyncThunk } from '@reduxjs/toolkit'; -import { normalizeInputsToJsonSchema } from '@kbn/workflows/spec/lib/input_conversion'; +import { normalizeFieldsToJsonSchema } from '@kbn/workflows/spec/lib/field_conversion'; import { searchWorkflows } from '@kbn/workflows-ui'; import type { WorkflowsServices } from '../../../../../types'; import type { WorkflowsResponse } from '../../../model/types'; @@ -41,7 +41,7 @@ export const loadWorkflowsThunk = createAsyncThunk< workflowsMap[workflow.id] = { id: workflow.id, name: workflow.name, - inputsSchema: normalizeInputsToJsonSchema(workflow.definition?.inputs), + inputsSchema: normalizeFieldsToJsonSchema(workflow.definition?.inputs), }; }); diff --git a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/utils/computation.test.ts b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/utils/computation.test.ts index c4a3d40beff80..cbd2395ee9d02 100644 --- a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/utils/computation.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/utils/computation.test.ts @@ -325,4 +325,49 @@ steps: expect(result.workflowGraph).toBeDefined(); }); }); + + describe('JSON Schema outputs', () => { + it('should preserve outputs in JSON Schema format from performComputation', () => { + const yaml = `name: New workflow +enabled: true +description: This is a new workflow +triggers: + - type: alert + +inputs: + - name: message + type: string + default: "hello world" + +outputs: + required: + - b + properties: + a: + type: string + default: "aaaa" + b: + type: number + +steps: + - name: hello_world_step + type: console + with: + message: "{{ event.alerts[0] | json: 2 }}" + + - name: return + type: workflow.output + with: + a: {}`; + + const result = performComputation(yaml); + + expect(result.workflowDefinition).toBeDefined(); + expect(result.workflowDefinition?.outputs).toBeDefined(); + + const outputs = result.workflowDefinition?.outputs as Record; + expect(outputs?.properties).toBeDefined(); + expect(outputs?.required).toEqual(['b']); + }); + }); }); diff --git a/src/platform/plugins/shared/workflows_management/public/features/run_workflow/ui/workflow_execute_manual_form.tsx b/src/platform/plugins/shared/workflows_management/public/features/run_workflow/ui/workflow_execute_manual_form.tsx index 5d84b12864209..60ea708cd15c7 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/run_workflow/ui/workflow_execute_manual_form.tsx +++ b/src/platform/plugins/shared/workflows_management/public/features/run_workflow/ui/workflow_execute_manual_form.tsx @@ -13,13 +13,13 @@ import React, { useCallback, useEffect, useMemo } from 'react'; import { CodeEditor } from '@kbn/code-editor'; import { i18n } from '@kbn/i18n'; import type { WorkflowYaml } from '@kbn/workflows'; +import { buildFieldsZodValidator } from '@kbn/workflows/spec/lib/build_fields_zod_validator'; import { applyInputDefaults, - normalizeInputsToJsonSchema, -} from '@kbn/workflows/spec/lib/input_conversion'; + normalizeFieldsToJsonSchema, +} from '@kbn/workflows/spec/lib/field_conversion'; import type { z } from '@kbn/zod/v4'; import { generateSampleFromJsonSchema } from '../../../../common/lib/generate_sample_from_json_schema'; -import { buildInputsZodValidator } from '../../../../common/lib/json_schema_to_zod'; import { WORKFLOWS_MONACO_EDITOR_THEME } from '../../../widgets/workflow_yaml_editor/styles/use_workflows_monaco_theme'; interface WorkflowExecuteManualFormProps { @@ -32,7 +32,7 @@ interface WorkflowExecuteManualFormProps { const getDefaultWorkflowInput = (definition: WorkflowYaml): string => { // Normalize inputs to the new JSON Schema format (handles backward compatibility) - const normalizedInputs = normalizeInputsToJsonSchema(definition.inputs); + const normalizedInputs = normalizeFieldsToJsonSchema(definition.inputs); if (!normalizedInputs?.properties) { return '{}'; @@ -65,7 +65,7 @@ export const WorkflowExecuteManualForm = ({ setErrors, }: WorkflowExecuteManualFormProps): React.JSX.Element => { const inputsValidator = useMemo( - () => buildInputsZodValidator(normalizeInputsToJsonSchema(definition?.inputs)), + () => buildFieldsZodValidator(normalizeFieldsToJsonSchema(definition?.inputs)), [definition?.inputs] ); diff --git a/src/platform/plugins/shared/workflows_management/public/features/run_workflow/ui/workflow_execute_modal.tsx b/src/platform/plugins/shared/workflows_management/public/features/run_workflow/ui/workflow_execute_modal.tsx index f4198df662bf8..0baca9048cd71 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/run_workflow/ui/workflow_execute_modal.tsx +++ b/src/platform/plugins/shared/workflows_management/public/features/run_workflow/ui/workflow_execute_modal.tsx @@ -27,7 +27,7 @@ import { parseDocument } from 'yaml'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { WorkflowYaml } from '@kbn/workflows'; -import { normalizeInputsToJsonSchema } from '@kbn/workflows/spec/lib/input_conversion'; +import { normalizeFieldsToJsonSchema } from '@kbn/workflows/spec/lib/field_conversion'; import { ENABLED_TRIGGER_TABS } from './constants'; import { TRIGGER_TABS_DESCRIPTIONS, TRIGGER_TABS_LABELS } from './translations'; import type { WorkflowTriggerTab } from './types'; @@ -44,7 +44,7 @@ function getDefaultTrigger(definition: WorkflowYaml | null): WorkflowTriggerTab const hasManualTrigger = definition.triggers?.some((trigger) => trigger.type === 'manual'); // Check if inputs exist and have properties (handles both new and legacy formats) - const normalizedInputs = normalizeInputsToJsonSchema(definition.inputs); + const normalizedInputs = normalizeFieldsToJsonSchema(definition.inputs); const hasInputs = normalizedInputs?.properties && Object.keys(normalizedInputs.properties).length > 0; @@ -119,8 +119,7 @@ export const WorkflowExecuteModal = React.memo( return false; } const hasAlertTrigger = definition.triggers?.some((trigger) => trigger.type === 'alert'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const normalizedInputs = normalizeInputsToJsonSchema(inputs as any); + const normalizedInputs = normalizeFieldsToJsonSchema(inputs); const hasInputs = normalizedInputs?.properties && Object.keys(normalizedInputs.properties).length > 0; if (!hasAlertTrigger && !hasInputs) { @@ -143,8 +142,7 @@ export const WorkflowExecuteModal = React.memo( setSelectedTrigger('alert'); return; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const normalizedInputs = normalizeInputsToJsonSchema(inputs as any); + const normalizedInputs = normalizeFieldsToJsonSchema(inputs); const hasInputs = normalizedInputs?.properties && Object.keys(normalizedInputs.properties).length > 0; if (hasInputs) { diff --git a/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/lib/use_yaml_validation.ts b/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/lib/use_yaml_validation.ts index 2481e2d9989b0..ba2055c20aac2 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/lib/use_yaml_validation.ts +++ b/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/lib/use_yaml_validation.ts @@ -22,6 +22,7 @@ import { validateStepNameUniqueness } from './validate_step_name_uniqueness'; import { validateTriggerConditions } from './validate_trigger_conditions'; import { validateVariables as validateVariablesInternal } from './validate_variables'; import { validateWorkflowInputs } from './validate_workflow_inputs'; +import { validateWorkflowOutputsInYaml } from './validate_workflow_outputs_in_yaml'; import { getPropertyHandler } from '../../../../common/schema'; import { selectWorkflowGraph, selectYamlDocument } from '../../../entities/workflows/store'; import { @@ -45,6 +46,8 @@ const SEVERITY_MAP = { export interface UseYamlValidationResult { error: Error | null; isLoading: boolean; + /** Custom validation results (source of truth for accordion; avoids interceptor timing issues) */ + validationResults: YamlValidationResult[]; } export function useYamlValidation( @@ -52,6 +55,7 @@ export function useYamlValidation( ): UseYamlValidationResult { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [validationResults, setValidationResults] = useState([]); const decorationsCollection = useRef(null); const yamlDocument = useSelector(selectYamlDocument); const workflowLookup = useSelector(selectEditorWorkflowLookup); @@ -75,19 +79,20 @@ export function useYamlValidation( } if (!isWorkflowTab) { - // clear decorations and markers if (decorationsCollection.current) { decorationsCollection.current.clear(); } CUSTOM_YAML_VALIDATION_MARKER_OWNERS.forEach((owner) => { monaco.editor.setModelMarkers(model, owner, []); }); + setValidationResults([]); setIsLoading(false); setError(null); return; } if (!yamlDocument) { + setValidationResults([]); setIsLoading(false); setError(new Error('Error validating: Yaml document is not loaded')); return; @@ -116,10 +121,11 @@ export function useYamlValidation( // (e.g. during editing when the YAML doesn't fully match the workflow schema yet) // so that connector-id, step-name, liquid-template, custom-property, and // workflow-inputs validation still provide feedback. - const validationResults: YamlValidationResult[] = [ + const results: YamlValidationResult[] = [ ...validateStepNameUniqueness(yamlDocument), ...validateLiquidTemplate(model.getValue(), yamlDocument), ...validateConnectorIds(connectorIdItems, dynamicConnectorTypes, connectorsManagementUrl), + ...validateWorkflowOutputsInYaml(yamlDocument, model, workflowDefinition?.outputs), ...(customPropertyItems ? await validateCustomProperties(customPropertyItems) : []), ...(workflowLookup && lineCounter ? [ @@ -136,7 +142,7 @@ export function useYamlValidation( // above still provide feedback. if (workflowGraph && workflowDefinition) { const variableItems = collectAllVariables(model, yamlDocument, workflowGraph); - validationResults.push( + results.push( ...validateTriggerConditions(workflowDefinition, yamlDocument), ...validateVariablesInternal( variableItems, @@ -149,15 +155,15 @@ export function useYamlValidation( ); } - const { markers, decorations } = createMarkersAndDecorations(validationResults); + const { markers, decorations } = createMarkersAndDecorations(results); if (decorationsCollection.current) { decorationsCollection.current.clear(); } decorationsCollection.current = editor.createDecorationsCollection(decorations); + setValidationResults(results); setIsLoading(false); - // Set markers on the model for the problems panel CUSTOM_YAML_VALIDATION_MARKER_OWNERS.forEach((owner) => { monaco.editor.setModelMarkers( model, @@ -185,6 +191,7 @@ export function useYamlValidation( return { error, isLoading, + validationResults, }; } @@ -295,6 +302,23 @@ function createMarkersAndDecorations(validationResults: YamlValidationResult[]): range: createRange(validationResult), options: createSelectionDecoration(validationResult), }); + } else if (validationResult.owner === 'workflow-output-validation') { + markers.push({ + ...marker, + severity: SEVERITY_MAP[validationResult.severity], + message: validationResult.message, + source: 'workflow-output-validation', + }); + decorations.push({ + range: createRange(validationResult), + options: { + inlineClassName: `workflow-output-validation-${validationResult.severity}`, + stickiness: monaco.editor.TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + hoverMessage: validationResult.hoverMessage + ? createMarkdownContent(validationResult.hoverMessage) + : null, + }, + }); } else { if (validationResult.severity !== null) { markers.push({ diff --git a/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/lib/validate_json_schema_defaults.ts b/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/lib/validate_json_schema_defaults.ts index 88f8eea34ab98..b74a6f34c3552 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/lib/validate_json_schema_defaults.ts +++ b/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/lib/validate_json_schema_defaults.ts @@ -12,8 +12,8 @@ import type { Document } from 'yaml'; import { isPair, isScalar, visit } from 'yaml'; import type { monaco } from '@kbn/monaco'; import type { WorkflowYaml } from '@kbn/workflows'; -import { normalizeInputsToJsonSchema, resolveRef } from '@kbn/workflows/spec/lib/input_conversion'; -import { convertJsonSchemaToZod } from '../../../../common/lib/json_schema_to_zod'; +import { convertJsonSchemaToZod } from '@kbn/workflows/spec/lib/build_fields_zod_validator'; +import { normalizeFieldsToJsonSchema, resolveRef } from '@kbn/workflows/spec/lib/field_conversion'; import { getPathFromAncestors } from '../../../../common/lib/yaml'; import type { YamlValidationResult } from '../model/types'; @@ -52,7 +52,7 @@ export function validateJsonSchemaDefaults( } // Normalize inputs to JSON Schema format - const normalizedInputs = normalizeInputsToJsonSchema(inputs); + const normalizedInputs = normalizeFieldsToJsonSchema(inputs); // Defensive check: ensure normalizedInputs has valid properties object if ( !normalizedInputs || @@ -109,7 +109,7 @@ export function validateJsonSchemaDefaults( } // Only build property map if properties exist and is a valid object (not array, not null) - // Note: normalizeInputsToJsonSchema already converts legacy array format to JSON Schema format, + // Note: normalizeFieldsToJsonSchema already converts legacy array format to JSON Schema format, // so normalizedInputs.properties contains all properties regardless of input format if ( normalizedInputs.properties && diff --git a/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/lib/validate_workflow_inputs.ts b/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/lib/validate_workflow_inputs.ts index c5fac1e490184..4929cfd07be23 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/lib/validate_workflow_inputs.ts +++ b/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/lib/validate_workflow_inputs.ts @@ -10,8 +10,8 @@ import type { JSONSchema7 } from 'json-schema'; import type { LineCounter } from 'yaml'; import { i18n } from '@kbn/i18n'; +import { convertJsonSchemaToZodWithRefs } from '@kbn/workflows/spec/lib/build_fields_zod_validator'; import type { JsonModelSchemaType } from '@kbn/workflows/spec/schema/common/json_model_schema'; -import { convertJsonSchemaToZodWithRefs } from '../../../../common/lib/json_schema_to_zod'; import { isDynamicValue } from '../../../../common/lib/regex'; import type { WorkflowsResponse } from '../../../entities/workflows/model/types'; import { diff --git a/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/lib/validate_workflow_outputs_in_yaml.test.ts b/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/lib/validate_workflow_outputs_in_yaml.test.ts new file mode 100644 index 0000000000000..6703ad7a16c4a --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/lib/validate_workflow_outputs_in_yaml.test.ts @@ -0,0 +1,222 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import YAML from 'yaml'; +import type { JsonModelSchemaType } from '@kbn/workflows/spec/schema/common/json_model_schema'; +import { validateWorkflowOutputsInYaml } from './validate_workflow_outputs_in_yaml'; +import { parseWorkflowYamlForAutocomplete } from '../../../../common/lib/yaml'; +import { createFakeMonacoModel } from '../../../../common/mocks/monaco_model'; + +describe('validateWorkflowOutputsInYaml', () => { + const buildTestContext = (yamlString: string) => { + const yamlDocument = YAML.parseDocument(yamlString, { keepSourceTokens: true }); + const model = createFakeMonacoModel(yamlString) as any; + return { yamlDocument, model }; + }; + + describe('JSON Schema format outputs', () => { + it('should detect missing required field and wrong type for workflow.output (exact user YAML)', () => { + const yamlString = `name: New workflow +enabled: true +description: This is a new workflow +triggers: + - type: alert + +inputs: + - name: message + type: string + default: "hello world" + +outputs: + required: + - b + properties: + a: + type: string + default: "aaaa" + b: + type: number + +steps: + - name: hello_world_step + type: console + with: + message: "{{ event.alerts[0] | json: 2 }}" + + - name: return + type: workflow.output + with: + a: {}`; + + const { yamlDocument, model } = buildTestContext(yamlString); + + const parseResult = parseWorkflowYamlForAutocomplete(yamlString); + expect(parseResult.success).toBe(true); + + const workflowOutputs = parseResult.success + ? (parseResult.data.outputs as JsonModelSchemaType) + : undefined; + expect(workflowOutputs).toBeDefined(); + expect(workflowOutputs?.required).toEqual(['b']); + + const results = validateWorkflowOutputsInYaml(yamlDocument, model, workflowOutputs); + + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results.some((r) => r.severity === 'error')).toBe(true); + }); + + it('should detect missing required field when only optional field provided', () => { + const yamlString = `name: test +outputs: + required: + - b + properties: + a: + type: string + b: + type: number +steps: + - name: return + type: workflow.output + with: + a: hello`; + + const { yamlDocument, model } = buildTestContext(yamlString); + const parseResult = parseWorkflowYamlForAutocomplete(yamlString); + const workflowOutputs = parseResult.success + ? (parseResult.data.outputs as JsonModelSchemaType) + : undefined; + + const results = validateWorkflowOutputsInYaml(yamlDocument, model, workflowOutputs); + + expect(results.length).toBeGreaterThanOrEqual(1); + const errorMessages = results.map((r) => r.message); + expect(errorMessages.some((m) => m?.includes('b'))).toBe(true); + }); + + it('should pass when all required fields are provided with correct types', () => { + const yamlString = `name: test +outputs: + required: + - b + properties: + a: + type: string + b: + type: number +steps: + - name: return + type: workflow.output + with: + a: hello + b: 42`; + + const { yamlDocument, model } = buildTestContext(yamlString); + const parseResult = parseWorkflowYamlForAutocomplete(yamlString); + const workflowOutputs = parseResult.success + ? (parseResult.data.outputs as JsonModelSchemaType) + : undefined; + + const results = validateWorkflowOutputsInYaml(yamlDocument, model, workflowOutputs); + + expect(results).toHaveLength(0); + }); + + it('should validate using outputs from YAML document when workflowOutputs is undefined (fallback)', () => { + const yamlString = `name: test +outputs: + required: + - b + properties: + a: + type: string + b: + type: number +steps: + - name: return + type: workflow.output + with: + a: {}`; + + const { yamlDocument, model } = buildTestContext(yamlString); + const results = validateWorkflowOutputsInYaml(yamlDocument, model, undefined); + + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results.some((r) => r.severity === 'error')).toBe(true); + }); + + it('should use fallback with legacy array outputs when workflowOutputs is undefined', () => { + const yamlString = `name: test +outputs: + - name: result + type: string + required: true +steps: + - name: return + type: workflow.output + with: + wrong: value`; + + const { yamlDocument, model } = buildTestContext(yamlString); + const results = validateWorkflowOutputsInYaml(yamlDocument, model, undefined); + + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results.some((r) => r.severity === 'error')).toBe(true); + }); + + it('should collect and validate workflow.output inside while, on-failure.fallback, and else', () => { + const yamlString = `name: test +outputs: + required: [x] + properties: + x: { type: number } +steps: + - name: branch + type: if + condition: "true" + steps: + - name: inner + type: while + max-iterations: 5 + condition: "false" + steps: + - name: out_while + type: workflow.output + with: + x: wrong + else: + - name: out_else + type: workflow.output + with: {} + - name: with_fallback + type: console + with: + message: hi + on-failure: + fallback: + - name: out_fallback + type: workflow.output + with: + x: 1`; + + const { yamlDocument, model } = buildTestContext(yamlString); + const parseResult = parseWorkflowYamlForAutocomplete(yamlString); + const workflowOutputs = parseResult.success + ? (parseResult.data.outputs as JsonModelSchemaType) + : undefined; + + const results = validateWorkflowOutputsInYaml(yamlDocument, model, workflowOutputs); + + const messages = results.map((r) => r.message); + expect(messages.some((m) => m?.includes('out_while') || m?.includes('"x"'))); + expect(messages.some((m) => m?.includes('out_else') || m?.includes('"x"'))); + expect(results.filter((r) => r.severity === 'error').length).toBeGreaterThanOrEqual(1); + }); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/lib/validate_workflow_outputs_in_yaml.ts b/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/lib/validate_workflow_outputs_in_yaml.ts new file mode 100644 index 0000000000000..a6d73e15700b4 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/lib/validate_workflow_outputs_in_yaml.ts @@ -0,0 +1,238 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { isMap, isPair, isScalar } from 'yaml'; +import type { Document, Node, YAMLMap } from 'yaml'; +import type { monaco } from '@kbn/monaco'; +import { + type NormalizableFieldSchema, + normalizeFieldsToJsonSchema, +} from '@kbn/workflows/spec/lib/field_conversion'; +import { getStepNodesWithType } from '../../../../common/lib/yaml/get_step_nodes_with_type'; +import { getMonacoRangeFromYamlNode } from '../../../widgets/workflow_yaml_editor/lib/utils'; +import { validateWorkflowFields } from '../../../widgets/workflow_yaml_editor/lib/validation/validate_workflow_fields'; +import type { YamlValidationResult } from '../model/types'; + +interface WorkflowOutputStepItem { + id: string; + yamlNode: Node; + withNode: Node | null; + /** Key node of the 'with' pair (for underlining only the "with:" line when required field is missing) */ + withKeyNode: Node | null; + withValues: Record; + model: monaco.editor.ITextModel; +} + +function getStepType(stepNode: YAMLMap): string | null { + const typePair = stepNode.items.find( + (item) => isPair(item) && isScalar(item.key) && item.key.value === 'type' + ); + if (typePair && isPair(typePair) && isScalar(typePair.value)) { + return String(typePair.value.value); + } + return null; +} + +function collectWorkflowOutputStepFromNode( + stepNode: YAMLMap, + items: WorkflowOutputStepItem[], + model: monaco.editor.ITextModel +): void { + const withPair = stepNode.items.find( + (item) => isPair(item) && isScalar(item.key) && item.key.value === 'with' + ); + + let withNode: Node | null = null; + const withValues: Record = {}; + + let withKeyNode: Node | null = null; + if (withPair && isPair(withPair)) { + withNode = withPair.value as Node; + if (isScalar(withPair.key)) { + withKeyNode = withPair.key; + } + + if (isMap(withPair.value)) { + withPair.value.items.forEach((item) => { + if (isPair(item) && isScalar(item.key)) { + const key = String(item.key.value); + let value: unknown = null; + + if (isScalar(item.value)) { + value = item.value.value; + } else if (Array.isArray(item.value)) { + value = item.value; + } else if (isMap(item.value)) { + value = {}; + } + + withValues[key] = value; + } + }); + } + } + + const stepRange = stepNode.range; + const stepId = stepRange + ? `workflow-output-${stepRange[0]}-${stepRange[2]}` + : `workflow-output-${items.length}`; + + items.push({ + id: stepId, + yamlNode: stepNode, + withNode, + withKeyNode, + withValues, + model, + }); +} + +/** + * Collects all workflow.output steps from the YAML document using the shared AST visitor. + * Covers steps at any nesting depth (if, foreach, while, on-failure, etc.). + */ +function collectWorkflowOutputSteps( + yamlDocument: Document, + model: monaco.editor.ITextModel +): WorkflowOutputStepItem[] { + const allStepNodes = getStepNodesWithType(yamlDocument); + const outputStepNodes = allStepNodes.filter((node) => getStepType(node) === 'workflow.output'); + const items: WorkflowOutputStepItem[] = []; + for (const node of outputStepNodes) { + collectWorkflowOutputStepFromNode(node, items, model); + } + return items; +} + +/** + * Reads the outputs section from the raw YAML document as a plain object or array. + * Used when workflowDefinition.outputs is missing so we can still validate workflow.output + * steps. That happens when: + * - The workflow YAML fails schema parse (e.g. structural error) and the parsed + * workflow definition is undefined or stripped. + * - Validations run before the full workflow graph/definition is available. + * In those cases the editor still has the raw yamlDocument; we read `outputs` from it + * so workflow.output `with:` validation can run. + * Supports both JSON Schema format (object with properties) and legacy array format + * ([{ name, type, required?, ... }]) so that either declaration style is validated. + * Exported for use by autocomplete (get_workflow_outputs_suggestions) when workflowDefinition.outputs is undefined. + */ +export function getOutputsFromYamlDocument( + yamlDocument: Document +): NormalizableFieldSchema | undefined { + const outputsNode = yamlDocument.get('outputs', true); + if (outputsNode == null) return undefined; + const plain = + typeof (outputsNode as Node).toJSON === 'function' + ? (outputsNode as Node).toJSON() + : outputsNode; + if (!plain || typeof plain !== 'object') return undefined; + + // JSON Schema format: { properties: { ... } } + if ( + !Array.isArray(plain) && + 'properties' in plain && + typeof (plain as Record).properties === 'object' + ) { + return plain as NormalizableFieldSchema; + } + + // Legacy array format: [{ name, type, required?, ... }] + if ( + Array.isArray(plain) && + plain.length > 0 && + plain.every( + (item) => item != null && typeof item === 'object' && 'name' in item && 'type' in item + ) + ) { + return plain as NormalizableFieldSchema; + } + + return undefined; +} + +/** + * Validates workflow.output steps against the workflow's declared outputs + */ +export function validateWorkflowOutputsInYaml( + yamlDocument: Document, + model: monaco.editor.ITextModel, + workflowOutputs: NormalizableFieldSchema | undefined +): YamlValidationResult[] { + const results: YamlValidationResult[] = []; + + const outputsSchema = workflowOutputs ?? getOutputsFromYamlDocument(yamlDocument); + const normalized = normalizeFieldsToJsonSchema(outputsSchema); + if (!normalized?.properties || Object.keys(normalized.properties).length === 0) { + return results; + } + + const outputSteps = collectWorkflowOutputSteps(yamlDocument, model); + + for (const outputStep of outputSteps) { + const validation = validateWorkflowFields(outputStep.withValues, outputsSchema, 'output'); + + if (!validation.isValid) { + for (const error of validation.errors) { + const errorNode = error.fieldName + ? findFieldNodeInWith(outputStep.withNode, error.fieldName) + : outputStep.withNode; + + let range: ReturnType = null; + if (errorNode) { + range = getMonacoRangeFromYamlNode(model, errorNode); + } else if (error.fieldName && outputStep.withKeyNode?.range) { + range = getMonacoRangeFromYamlNode(model, outputStep.withKeyNode); + } else if (outputStep.yamlNode.range) { + range = getMonacoRangeFromYamlNode(model, outputStep.yamlNode); + } + + if (range) { + // Prefix field name to the error message for clarity + const message = error.fieldName + ? `"${error.fieldName}": ${error.message}` + : error.message; + + results.push({ + id: `${outputStep.id}-${error.fieldName || 'general'}`, + owner: 'workflow-output-validation', + severity: 'error', + message, + hoverMessage: null, + startLineNumber: range.startLineNumber, + startColumn: range.startColumn, + endLineNumber: range.endLineNumber, + endColumn: range.endColumn, + }); + } + } + } + } + + return results; +} + +/** + * Finds a specific field node within a 'with' map node + */ +function findFieldNodeInWith(withNode: Node | null, fieldName: string): Node | null { + if (!withNode || !isMap(withNode)) { + return null; + } + + const fieldPair = withNode.items.find( + (item) => isPair(item) && isScalar(item.key) && item.key.value === fieldName + ); + + if (fieldPair && isPair(fieldPair)) { + return fieldPair.value as Node; + } + + return null; +} diff --git a/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/model/types.ts b/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/model/types.ts index c2fc6ca3e507f..21e27fc64f8b3 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/model/types.ts +++ b/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/model/types.ts @@ -128,6 +128,12 @@ interface YamlValidationResultTriggerConditionError extends YamlValidationResult owner: 'trigger-condition-validation'; } +interface YamlValidationResultWorkflowOutput extends YamlValidationResultBase { + severity: YamlValidationErrorSeverity; + message: string; + owner: 'workflow-output-validation'; +} + interface YamlValidationResultIfConditionError extends YamlValidationResultBase { severity: YamlValidationErrorSeverity; message: string; @@ -153,6 +159,7 @@ export const CUSTOM_YAML_VALIDATION_MARKER_OWNERS = [ 'custom-property-validation', 'workflow-inputs-validation', 'trigger-condition-validation', + 'workflow-output-validation', 'if-condition-validation', ] as const; @@ -175,4 +182,5 @@ export type YamlValidationResult = | YamlValidationResultCustomPropertyValid | YamlValidationResultWorkflowInputsError | YamlValidationResultTriggerConditionError + | YamlValidationResultWorkflowOutput | YamlValidationResultIfConditionError; diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_context/lib/get_workflow_context_schema.ts b/src/platform/plugins/shared/workflows_management/public/features/workflow_context/lib/get_workflow_context_schema.ts index dabc0291e3d16..d92ba6885cd6f 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/workflow_context/lib/get_workflow_context_schema.ts +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_context/lib/get_workflow_context_schema.ts @@ -16,9 +16,9 @@ import { EventTimestampSchema, isTriggerType, } from '@kbn/workflows'; -import { normalizeInputsToJsonSchema } from '@kbn/workflows/spec/lib/input_conversion'; +import { buildFieldsZodValidator } from '@kbn/workflows/spec/lib/build_fields_zod_validator'; +import { normalizeFieldsToJsonSchema } from '@kbn/workflows/spec/lib/field_conversion'; import { z } from '@kbn/zod/v4'; -import { buildInputsZodValidator } from '../../../../common/lib/json_schema_to_zod'; import { inferZodType } from '../../../../common/lib/zod'; import { triggerSchemas } from '../../../trigger_schemas'; @@ -64,40 +64,46 @@ function buildEventSchemaFromTriggers(triggers: Array<{ type?: string }>): z.Zod return eventSchema.optional(); } +/** + * Extracts a field value from the definition, falling back to the YAML document if not present. + */ +function extractFieldFromYaml( + definitionValue: T | undefined, + yamlDocument: Document | null | undefined, + fieldName: string +): T | undefined { + if (definitionValue !== undefined) { + return definitionValue; + } + if (!yamlDocument) { + return undefined; + } + try { + const yamlJson = yamlDocument.toJSON(); + if (yamlJson && typeof yamlJson === 'object' && fieldName in yamlJson) { + return (yamlJson as Record)[fieldName] as T; + } + } catch { + // Ignore errors when extracting from YAML + } + return undefined; +} + export function getWorkflowContextSchema( definition: WorkflowDefinitionForContext, yamlDocument?: Document | null ): typeof DynamicWorkflowContextSchema { - // If inputs is undefined, try to extract it from the YAML document - let inputs = definition.inputs; - if (inputs === undefined && yamlDocument) { - try { - const yamlJson = yamlDocument.toJSON(); - if (yamlJson && typeof yamlJson === 'object' && 'inputs' in yamlJson) { - inputs = (yamlJson as Record).inputs as typeof inputs; - } - } catch (e) { - // Ignore errors when extracting from YAML - } - } + const inputs = extractFieldFromYaml(definition.inputs, yamlDocument, 'inputs'); + const outputs = extractFieldFromYaml(definition.outputs, yamlDocument, 'outputs'); - // Normalize inputs to the new JSON Schema format (handles backward compatibility) - // This handles both array (legacy) and object (new) formats - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const normalizedInputs = normalizeInputsToJsonSchema(inputs as any); - const inputsSchema = buildInputsZodValidator(normalizedInputs); + const normalizedInputs = normalizeFieldsToJsonSchema(inputs); + const normalizedOutputs = normalizeFieldsToJsonSchema(outputs); const eventSchema = buildEventSchemaFromTriggers(definition.triggers ?? []); - // Use DynamicWorkflowContextSchema instead of WorkflowContextSchema - // This ensures compatibility with DynamicStepContextSchema.merge() in getContextSchemaForPath - // The merge() method requires both schemas to have the same base structure. - // Cast to typeof DynamicWorkflowContextSchema because inputsSchema is ZodType> - // (from buildInputsZodValidator) while the base schema expects ZodObject; runtime shape is compatible. return DynamicWorkflowContextSchema.extend({ - inputs: inputsSchema, - // transform an object of consts to an object - // with the const name as the key and inferred type as the value + inputs: buildFieldsZodValidator(normalizedInputs), + output: buildFieldsZodValidator(normalizedOutputs), consts: z.object({ ...Object.fromEntries( Object.entries(definition.consts ?? {}).map(([key, value]) => [ @@ -106,7 +112,6 @@ export function getWorkflowContextSchema( ]) ), }), - // event schema is dynamic based on triggers event: eventSchema, }) as typeof DynamicWorkflowContextSchema; } diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_pseudo_step_context.test.ts b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_pseudo_step_context.test.ts index b460e6acb4f39..042c4d469b8b8 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_pseudo_step_context.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_pseudo_step_context.test.ts @@ -7,7 +7,12 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { buildTriggerContextFromExecution } from './workflow_pseudo_step_context'; +import type { WorkflowExecutionDto } from '@kbn/workflows'; +import { ExecutionStatus } from '@kbn/workflows'; +import { + buildTriggerContextFromExecution, + buildTriggerStepExecutionFromContext, +} from './workflow_pseudo_step_context'; describe('buildTriggerContextFromExecution', () => { it('should return null when context is null', () => { @@ -64,3 +69,49 @@ describe('buildTriggerContextFromExecution', () => { expect(result?.input).toEqual({ foo: 'bar' }); }); }); + +describe('buildTriggerStepExecutionFromContext', () => { + const baseExecution: WorkflowExecutionDto = { + spaceId: 'default', + id: 'exec-1', + status: ExecutionStatus.COMPLETED, + error: null, + isTestRun: false, + startedAt: '2024-01-01T00:00:00Z', + finishedAt: '2024-01-01T00:01:00Z', + workflowId: 'wf-1', + workflowName: 'Test', + workflowDefinition: {} as WorkflowExecutionDto['workflowDefinition'], + stepExecutions: [], + duration: 60000, + yaml: '', + }; + + it('returns null when context is null', () => { + expect( + buildTriggerStepExecutionFromContext({ + ...baseExecution, + context: null, + } as unknown as WorkflowExecutionDto) + ).toBeNull(); + }); + + it('sets output from context.output when present', () => { + const output = { greeting: 'hello world', count: 42 }; + const result = buildTriggerStepExecutionFromContext({ + ...baseExecution, + context: { inputs: {}, output }, + }); + expect(result).not.toBeNull(); + expect(result?.output).toEqual(output); + }); + + it('sets output to undefined when context has no output', () => { + const result = buildTriggerStepExecutionFromContext({ + ...baseExecution, + context: { inputs: { key: 'value' } }, + }); + expect(result).not.toBeNull(); + expect(result?.output).toBeUndefined(); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_pseudo_step_context.ts b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_pseudo_step_context.ts index 131368d78c877..34e779afc4516 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_pseudo_step_context.ts +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_pseudo_step_context.ts @@ -66,7 +66,7 @@ export function buildTriggerStepExecutionFromContext( stepType: `trigger_${triggerContext.triggerType}`, status: ExecutionStatus.COMPLETED, input: triggerContext.input, - output: undefined, + output: (workflowExecution.context?.output as JsonValue) ?? undefined, scopeStack: [], workflowRunId: workflowExecution.id, workflowId: workflowExecution.workflowId || '', diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_step_execution_details.test.tsx b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_step_execution_details.test.tsx new file mode 100644 index 0000000000000..ffc883ad043a3 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_step_execution_details.test.tsx @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import type { WorkflowStepExecutionDto } from '@kbn/workflows'; +import { ExecutionStatus } from '@kbn/workflows'; +import { WorkflowStepExecutionDetails } from './workflow_step_execution_details'; +import { TestWrapper } from '../../../shared/test_utils'; + +jest.mock('./step_execution_data_view', () => ({ + StepExecutionDataView: () =>
, +})); + +jest.mock('./workflow_execution_overview', () => ({ + WorkflowExecutionOverview: () =>
, +})); + +const createTriggerStep = ( + overrides: Partial = {} +): WorkflowStepExecutionDto => ({ + id: 'trigger', + stepId: 'manual', + stepType: 'trigger_manual', + status: ExecutionStatus.COMPLETED, + scopeStack: [], + workflowRunId: 'exec-1', + workflowId: 'wf-1', + startedAt: '', + globalExecutionIndex: -1, + stepExecutionIndex: 0, + topologicalIndex: -1, + ...overrides, +}); + +const createRegularStep = ( + overrides: Partial = {} +): WorkflowStepExecutionDto => ({ + id: 'step-1', + stepId: 'emit', + stepType: 'workflow.output', + status: ExecutionStatus.COMPLETED, + scopeStack: [], + workflowRunId: 'exec-1', + workflowId: 'wf-1', + startedAt: '2024-01-01T00:00:00Z', + globalExecutionIndex: 0, + stepExecutionIndex: 0, + topologicalIndex: 0, + input: {}, + output: {}, + ...overrides, +}); + +describe('WorkflowStepExecutionDetails', () => { + it('shows Input and Output tabs for trigger when both input and output exist, Input first and selected by default', () => { + const stepExecution = createTriggerStep({ + input: { foo: 'bar' }, + output: { greeting: 'hello world' }, + }); + render( + + + + ); + + const inputTab = screen.getByRole('tab', { name: 'Input' }); + const outputTab = screen.getByRole('tab', { name: 'Output' }); + expect(inputTab).toBeInTheDocument(); + expect(outputTab).toBeInTheDocument(); + + const tabs = screen.getAllByRole('tab'); + expect(tabs[0]).toHaveTextContent('Input'); + expect(tabs[1]).toHaveTextContent('Output'); + expect(inputTab).toHaveAttribute('aria-selected', 'true'); + }); + + it('shows only Input tab for trigger when output is missing', () => { + const stepExecution = createTriggerStep({ input: { foo: 'bar' } }); + render( + + + + ); + + expect(screen.getByRole('tab', { name: 'Input' })).toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: 'Output' })).not.toBeInTheDocument(); + }); + + it('shows only Output tab for trigger when input is missing but output exists', () => { + const stepExecution = createTriggerStep({ output: { result: 'ok' } }); + render( + + + + ); + + expect(screen.getByRole('tab', { name: 'Output' })).toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: 'Input' })).not.toBeInTheDocument(); + }); + + it('shows Output then Input tabs for regular steps', () => { + const stepExecution = createRegularStep({ + input: { url: 'https://example.com' }, + output: { status: 200 }, + }); + render( + + + + ); + + const tabs = screen.getAllByRole('tab'); + expect(tabs[0]).toHaveTextContent('Output'); + expect(tabs[1]).toHaveTextContent('Input'); + }); + + it('renders with workflowExecutionTrigger data-test-subj for trigger pseudo-step', () => { + const stepExecution = createTriggerStep({ input: {} }); + const { container } = render( + + + + ); + expect( + container.querySelector('[data-test-subj="workflowExecutionTrigger"]') + ).toBeInTheDocument(); + }); + + it('renders with workflowStepExecutionDetails data-test-subj for regular step', () => { + const stepExecution = createRegularStep(); + const { container } = render( + + + + ); + expect( + container.querySelector('[data-test-subj="workflowStepExecutionDetails"]') + ).toBeInTheDocument(); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_step_execution_details.tsx b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_step_execution_details.tsx index c90a91bb764ca..664b2c6866cc0 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_step_execution_details.tsx +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_step_execution_details.tsx @@ -49,6 +49,7 @@ export const WorkflowStepExecutionDetails = React.memo { @@ -60,6 +61,12 @@ export const WorkflowStepExecutionDetails = React.memo(tabs[0].id); @@ -139,33 +146,7 @@ export const WorkflowStepExecutionDetails = React.memo {selectedTabId === 'output' && ( - <> - {isTriggerPseudoStep && ( - <> - - {`{{ }}`}, - }} - /> - - - - )} - - + )} {selectedTabId === 'input' && ( <> diff --git a/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/get_step_icon_type.ts b/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/get_step_icon_type.ts index 821617bc27f9a..f4c992db24bed 100644 --- a/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/get_step_icon_type.ts +++ b/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/get_step_icon_type.ts @@ -7,7 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { IconType } from '@elastic/eui'; import type { EuiIconType } from '@elastic/eui/src/components/icon/icon'; +import { HardcodedIcons } from './hardcoded_icons'; export const getTriggerTypeIconType = (triggerType: string): EuiIconType => { switch (triggerType) { @@ -24,8 +26,10 @@ export const getTriggerTypeIconType = (triggerType: string): EuiIconType => { } }; -export const getStepIconType = (nodeType: string): EuiIconType => { - let iconType: EuiIconType = 'info'; +// Switch has good readability as it is +// eslint-disable-next-line complexity +export const getStepIconType = (nodeType: string): IconType => { + let iconType: IconType = 'info'; switch (nodeType) { // built-in node types @@ -39,8 +43,16 @@ export const getStepIconType = (nodeType: string): EuiIconType => { iconType = 'database'; break; case 'workflow.execute': + iconType = HardcodedIcons['workflow.execute']; + break; case 'workflow.executeAsync': - iconType = 'link'; + iconType = HardcodedIcons['workflow.executeAsync']; + break; + case 'workflow.output': + iconType = HardcodedIcons['workflow.output']; + break; + case 'workflow.fail': + iconType = HardcodedIcons['workflow.fail']; break; // flow control nodes diff --git a/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/hardcoded_icons.ts b/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/hardcoded_icons.ts index 2d2c7a52a7da8..690350adea186 100644 --- a/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/hardcoded_icons.ts +++ b/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/hardcoded_icons.ts @@ -13,11 +13,13 @@ import clock from './icons/clock.svg'; import console from './icons/console.svg'; import database from './icons/database.svg'; import email from './icons/email.svg'; +import fail from './icons/fail.svg'; import flask from './icons/flask.svg'; import glyph from './icons/glyph.svg'; import elasticsearchLogoSvg from './icons/logo_elasticsearch.svg'; import kibanaLogoSvg from './icons/logo_kibana.svg'; import slackLogoSvg from './icons/logo_slack.svg'; +import output from './icons/output.svg'; import plugs from './icons/plugs.svg'; import refresh from './icons/refresh.svg'; import sparkles from './icons/sparkles.svg'; @@ -43,6 +45,8 @@ export const HardcodedIcons: Record = { manual: user, 'workflow.execute': glyph, 'workflow.executeAsync': union, + 'workflow.output': output, + 'workflow.fail': fail, trigger: bolt, flask, default: plugs, diff --git a/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/icons/fail.svg b/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/icons/fail.svg new file mode 100644 index 0000000000000..3691b8193cc98 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/icons/fail.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/icons/output.svg b/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/icons/output.svg new file mode 100644 index 0000000000000..431b2c1c4bdab --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/icons/output.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/monochrome_icons.ts b/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/monochrome_icons.ts index 246bd88238caf..7d1af7eacf158 100644 --- a/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/monochrome_icons.ts +++ b/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/monochrome_icons.ts @@ -19,6 +19,8 @@ export const MonochromeIcons = new Set([ 'wait', 'workflow.execute', 'workflow.executeAsync', + 'workflow.output', + 'workflow.fail', // connector icons, which are monochrome and should be colored with currentColor '.http', '.inference', diff --git a/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/step_icon.tsx b/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/step_icon.tsx index 3d56f6034c917..99a68f46e98b3 100644 --- a/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/step_icon.tsx +++ b/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/step_icon.tsx @@ -68,7 +68,29 @@ export const StepIcon = React.memo( iconType = getStepIconType(stepType); } - if (iconType.startsWith('token')) { + if (typeof iconType === 'string' && iconType.startsWith('data:')) { + const statusColor = shouldApplyColorToIcon + ? getExecutionStatusColors(euiTheme, executionStatus).color + : undefined; + return ( + + ); + } + + if (typeof iconType === 'string' && iconType.startsWith('token')) { return ( (); + // Inside workflow.output's with: block, show only declared output field names so the user + // doesn't get generic YAML/JSON Schema keys; skip the YAML provider in that case. + const shouldUseExclusiveSuggestions = isInWorkflowOutputWithBlock( + autocompleteContext.focusedStepInfo + ); + let isIncomplete = false; - { - // Get suggestions from all stored YAML providers (excluding workflow provider) + if (!shouldUseExclusiveSuggestions) { const allYamlProviders = getAllYamlProviders(); - // Call all stored providers and add their suggestions incrementally for (const yamlProvider of allYamlProviders) { if (yamlProvider.provideCompletionItems) { try { @@ -141,8 +146,7 @@ export function getCompletionItemProvider( } } - // Then, get workflow-specific suggestions (variables, connectors, etc.) - // Start with workflow suggestions (they typically have snippets and get priority in deduplication) + // Workflow-specific suggestions (variables, connectors, workflow outputs, etc.) const workflowSuggestions = await getSuggestions({ ...autocompleteContext, model, diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/integration/variable.test.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/integration/variable.test.ts index 306ea488e24d5..1925c3030efc8 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/integration/variable.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/integration/variable.test.ts @@ -77,6 +77,7 @@ steps: 'event', 'kibanaUrl', 'now', + 'output', 'workflow', 'parent', 'steps', @@ -90,6 +91,7 @@ steps: '"{{ event$0 }}"', '"{{ execution$0 }}"', '"{{ kibanaUrl$0 }}"', + '"{{ output$0 }}"', '"{{ workflow$0 }}"', '"{{ inputs$0 }}"', '"{{ consts$0 }}"', @@ -119,6 +121,7 @@ steps: '{{ event$0 }}', '{{ execution$0 }}', '{{ kibanaUrl$0 }}', + '{{ output$0 }}', '{{ workflow$0 }}', '{{ inputs$0 }}', '{{ consts$0 }}', @@ -609,4 +612,118 @@ steps: expect(inputsSuggestions.length).toBeGreaterThan(0); }); }); + + describe('workflow.output with: block autocomplete', () => { + it('should not suggest output fields when cursor is on the same line as with: (aligned with other steps)', async () => { + const yamlContent = ` +version: "1" +name: "test" +outputs: + - name: result + type: string +triggers: + - type: manual +steps: + - name: emit + type: workflow.output + with: |<- +`.trim(); + const suggestions = await getSuggestions(yamlContent); + const labels = suggestions.map((s) => s.label); + // No output key suggestions on the "with:" line (same as console/custom property behavior) + expect(labels).not.toContain('result'); + }); + + it('should not suggest output keys when cursor is in a value position (after key:)', async () => { + const yamlContent = ` +version: "1" +name: "test" +outputs: + - name: result + type: string + - name: count + type: number +triggers: + - type: manual +steps: + - name: emit + type: workflow.output + with: + a: |<- +`.trim(); + const suggestions = await getSuggestions(yamlContent); + const labels = suggestions.map((s) => s.label); + expect(labels).not.toContain('result'); + expect(labels).not.toContain('count'); + }); + + it('should suggest declared output fields inside workflow.output with: block', async () => { + const yamlContent = ` +version: "1" +name: "test" +outputs: + - name: result + type: string + - name: count + type: number +triggers: + - type: manual +steps: + - name: emit + type: workflow.output + with: + |<- +`.trim(); + const suggestions = await getSuggestions(yamlContent); + const labels = suggestions.map((s) => s.label); + expect(labels).toContain('result'); + expect(labels).toContain('count'); + }); + + it('should not suggest already provided output keys', async () => { + const yamlContent = ` +version: "1" +name: "test" +outputs: + - name: result + type: string + - name: count + type: number +triggers: + - type: manual +steps: + - name: emit + type: workflow.output + with: + result: "hello" + |<- +`.trim(); + const suggestions = await getSuggestions(yamlContent); + const labels = suggestions.map((s) => s.label); + expect(labels).not.toContain('result'); + expect(labels).toContain('count'); + }); + + it('should not suggest output fields when none are declared', async () => { + const yamlContent = ` +version: "1" +name: "test" +outputs: + - name: result + type: string +triggers: + - type: manual +steps: + - name: emit + type: workflow.output + with: + result: "done" + |<- +`.trim(); + const suggestions = await getSuggestions(yamlContent); + const labels = suggestions.map((s) => s.label); + // When all outputs are already provided, no more output field suggestions + expect(labels).not.toContain('result'); + }); + }); }); diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_type/get_connector_type_suggestions.test.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_type/get_connector_type_suggestions.test.ts index ab338e0c90bfd..12b94d76032d9 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_type/get_connector_type_suggestions.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_type/get_connector_type_suggestions.test.ts @@ -189,7 +189,7 @@ describe('getConnectorTypeSuggestions', () => { const result = getConnectorTypeSuggestions('if', mockRange); const ifSuggestion = result.find((s) => s.label === 'if'); expect(ifSuggestion?.insertText).toBe('if:\n # snippet'); - expect(generateBuiltInStepSnippet).toHaveBeenCalledWith('if', {}); + expect(generateBuiltInStepSnippet).toHaveBeenCalledWith('if', {}, undefined); }); it('should generate snippets for connectors', () => { diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_type/get_connector_type_suggestions.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_type/get_connector_type_suggestions.ts index 9cd1553e6b062..4eb851d471b70 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_type/get_connector_type_suggestions.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_type/get_connector_type_suggestions.ts @@ -8,7 +8,7 @@ */ import { monaco } from '@kbn/monaco'; -import type { BuiltInStepType, ConnectorTypeInfo } from '@kbn/workflows'; +import type { BuiltInStepType, ConnectorTypeInfo, WorkflowOutput } from '@kbn/workflows'; import { DataSetStepSchema, ForEachStepSchema, @@ -22,6 +22,8 @@ import { WhileStepSchema, WorkflowExecuteAsyncStepSchema, WorkflowExecuteStepSchema, + WorkflowFailStepSchema, + WorkflowOutputStepSchema, } from '@kbn/workflows'; import { getCachedAllConnectors } from '../../../connectors_cache'; import { generateBuiltInStepSnippet } from '../../../snippets/generate_builtin_step_snippet'; @@ -37,7 +39,8 @@ export function getConnectorTypeSuggestions( typePrefix: string, range: monaco.IRange, dynamicConnectorTypes?: Record, - isInsideLoopBody = false + isInsideLoopBody = false, + workflowOutputs?: WorkflowOutput[] ): monaco.languages.CompletionItem[] { // Create a cache key based on the type prefix, context, and loop state const cacheKey = `${typePrefix}|${JSON.stringify(range)}|${isInsideLoopBody}`; @@ -121,7 +124,11 @@ export function getConnectorTypeSuggestions( ); matchingBuiltInTypes.forEach((stepType) => { - const snippetText = generateBuiltInStepSnippet(stepType.type as BuiltInStepType, {}); + const snippetText = generateBuiltInStepSnippet( + stepType.type as BuiltInStepType, + {}, + workflowOutputs + ); const extendedRange = { startLineNumber: range.startLineNumber, endLineNumber: range.endLineNumber, @@ -278,6 +285,16 @@ function getBuiltInStepTypesFromSchema(): Array<{ description: 'Execute another workflow (asynchronous)', icon: monaco.languages.CompletionItemKind.Function, }, + { + schema: WorkflowOutputStepSchema, + description: 'Output values from the workflow', + icon: monaco.languages.CompletionItemKind.Property, + }, + { + schema: WorkflowFailStepSchema, + description: 'Fail the workflow with a message', + icon: monaco.languages.CompletionItemKind.Constant, + }, ]; const stepTypes = stepSchemas.map(({ schema, description, icon, loopOnly = false }) => { diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/get_suggestions.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/get_suggestions.ts index 71a2309082b3a..5c8c3785bb3d5 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/get_suggestions.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/get_suggestions.ts @@ -23,6 +23,7 @@ import { getTimezoneSuggestions } from './timezone/get_timezone_suggestions'; import { getTriggerTypeSuggestions } from './trigger_type/get_trigger_type_suggestions'; import { getVariableSuggestions } from './variable/get_variable_suggestions'; import { getWorkflowInputsSuggestions } from './workflow/get_workflow_inputs_suggestions'; +import { getWorkflowOutputsSuggestions } from './workflow/get_workflow_outputs_suggestions'; import { getWorkflowSuggestions } from './workflow/get_workflow_suggestions'; import { getPropertyHandler } from '../../../../../../common/schema'; import type { @@ -162,7 +163,6 @@ export async function getSuggestions( return getRRuleSchedulingSuggestions(autocompleteContext.range); } - // Handle suggestions based on match type const matchTypeSuggestions = await handleMatchTypeSuggestions(autocompleteContext); if (matchTypeSuggestions !== null) { return matchTypeSuggestions; @@ -178,16 +178,12 @@ export async function getSuggestions( } } + const workflowOutputSuggestions = await getWorkflowOutputsSuggestions(autocompleteContext); + if (workflowOutputSuggestions.length > 0) { + return workflowOutputSuggestions; + } + // JSON Schema autocompletion for inputs.properties - // e.g. - // inputs: - // properties: - // myProperty: - // type: |<- (suggest: string, number, boolean, object, array, null) - // format: |<- (suggest: email, uri, date-time, etc.) - // enum: |<- (suggest enum values from schema) - // This should be checked BEFORE other type completions to avoid conflicts - // but AFTER variable/connector completions which are more specific const jsonSchemaSuggestions = getJsonSchemaSuggestions(autocompleteContext); if (jsonSchemaSuggestions.length > 0) { return jsonSchemaSuggestions; @@ -199,19 +195,4 @@ export async function getSuggestions( (stepType: string, scope: 'config' | 'input', key: string) => getPropertyHandler(stepType, scope, key) ); - - // TODO: Implement connector with block completion - // Connector with block completion - // e.g. - // steps: - // - name: search-alerts - // type: elasticsearch.search - // with: - // index: "alerts-*" - // query: - // range: - // "@timestamp": - // gte: "now-1h" - // |<- - // return []; } diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/workflow/get_workflow_outputs_suggestions.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/workflow/get_workflow_outputs_suggestions.ts new file mode 100644 index 0000000000000..59a4fa235bf54 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/workflow/get_workflow_outputs_suggestions.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { JSONSchema7 } from 'json-schema'; +import { monaco } from '@kbn/monaco'; +import { + type NormalizableFieldSchema, + normalizeFieldsToJsonSchema, +} from '@kbn/workflows/spec/lib/field_conversion'; +import { getPlaceholderForProperty } from './workflow_input_placeholder'; +import { getOutputsFromYamlDocument } from '../../../../../../features/validate_workflow_yaml/lib/validate_workflow_outputs_in_yaml'; +import type { AutocompleteContext } from '../../context/autocomplete.types'; + +function getRawOutputs(context: AutocompleteContext): NormalizableFieldSchema | undefined { + return ( + context.workflowDefinition?.outputs ?? + (context.yamlDocument ? getOutputsFromYamlDocument(context.yamlDocument) : undefined) + ); +} + +/** + * Returns true when the cursor is inside a workflow.output step (including nested in if/foreach). + * Uses focusedStepInfo from the YAML AST; no manual path or position scanning. + */ +export function isInWorkflowOutputWithBlock( + focusedStepInfo: AutocompleteContext['focusedStepInfo'] +): boolean { + return focusedStepInfo?.stepType === 'workflow.output'; +} + +function createOutputSuggestion( + name: string, + schema: JSONSchema7, + isRequired: boolean, + range: monaco.IRange +): monaco.languages.CompletionItem { + const type = (schema.type as string) || 'string'; + const placeholder = getPlaceholderForProperty(schema); + + return { + label: name, + kind: monaco.languages.CompletionItemKind.Property, + insertText: `${name}: ${placeholder}`, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range, + documentation: schema.description || `${type} output`, + detail: `${type}${isRequired ? ' (required)' : ' (optional)'}`, + preselect: isRequired, + }; +} + +/** + * Gets workflow outputs suggestions for autocomplete + * Used when typing in the with: section of a workflow.output step + * + * NOTE: When suggestions are returned, Monaco YAML provider suggestions are suppressed + * to ensure only declared output fields are suggested (see get_completion_item_provider.ts) + */ +export async function getWorkflowOutputsSuggestions( + autocompleteContext: AutocompleteContext +): Promise { + const { focusedStepInfo, range } = autocompleteContext; + + if (focusedStepInfo?.stepType !== 'workflow.output') { + return []; + } + + // Use workflowDefinition.outputs when available; otherwise read from current YAML document + // so autocomplete works even when computed workflow definition doesn't have outputs yet + const rawOutputs = getRawOutputs(autocompleteContext); + const normalizedOutputs = normalizeFieldsToJsonSchema(rawOutputs); + + if (!normalizedOutputs?.properties || Object.keys(normalizedOutputs.properties).length === 0) { + return []; + } + + // Get already provided output keys from the current step's with block. + // workflowLookup records leaf props (e.g. with.result, with.count), not the with map itself. + const existingKeys = new Set(); + if (focusedStepInfo.propInfos) { + for (const composedKey of Object.keys(focusedStepInfo.propInfos)) { + if (composedKey.startsWith('with.')) { + const fieldName = composedKey.slice('with.'.length).split('.')[0]; + if (fieldName) { + existingKeys.add(fieldName); + } + } + } + } + + // Output suggestions are KEYS (property names). Only show them when the cursor + // is in a key position — not in a value position (after "key: "). + // This matches how other steps work: custom property suggestions require + // focusedYamlPair (value position), while output suggestions are the inverse. + const trimmedLine = autocompleteContext.line.trimStart(); + if (trimmedLine.startsWith('with:') || /^[\w][\w-]*\s*:/.test(trimmedLine)) { + return []; + } + + const suggestions: monaco.languages.CompletionItem[] = []; + + for (const [name, propSchema] of Object.entries(normalizedOutputs.properties)) { + if (!existingKeys.has(name) && propSchema && typeof propSchema === 'object') { + const isRequired = normalizedOutputs.required?.includes(name) ?? false; + suggestions.push(createOutputSuggestion(name, propSchema as JSONSchema7, isRequired, range)); + } + } + + return suggestions; +} diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_builtin_step_snippet.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_builtin_step_snippet.ts index 4cda6d90c5052..fe17002c9854f 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_builtin_step_snippet.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_builtin_step_snippet.ts @@ -10,6 +10,10 @@ import type { ToStringOptions } from 'yaml'; import { stringify } from 'yaml'; import type { BuiltInStepType } from '@kbn/workflows'; +import { + type NormalizableFieldSchema, + normalizeFieldsToJsonSchema, +} from '@kbn/workflows/spec/lib/field_conversion'; interface GenerateBuiltInStepSnippetOptions { full?: boolean; @@ -18,15 +22,19 @@ interface GenerateBuiltInStepSnippetOptions { /** * Generates a YAML snippet for a built-in workflow step based on the specified type. - * @param stepType - The type of built-in step ('foreach', 'if', 'parallel', 'merge', 'http', 'wait', etc.) + * @param stepType - The type of built-in step ('foreach', 'if', 'parallel', 'merge', 'http', 'wait', 'workflow.output', 'workflow.fail', etc.) * @param options - Configuration options for snippet generation * @param options.full - Whether to include the full YAML structure with step name and type prefix * @param options.withStepsSection - Whether to include the "steps:" section + * @param workflowOutputs - Declared workflow outputs for workflow.output step snippet * @returns The formatted YAML step snippet with appropriate parameters and structure */ +// Switch is good readable +// eslint-disable-next-line complexity export function generateBuiltInStepSnippet( stepType: BuiltInStepType, - { full, withStepsSection }: GenerateBuiltInStepSnippetOptions = {} + { full, withStepsSection }: GenerateBuiltInStepSnippetOptions = {}, + workflowOutputs?: NormalizableFieldSchema ): string { const stringifyOptions: ToStringOptions = { indent: 2 }; let parameters: Record; // eslint-disable-line @typescript-eslint/no-explicit-any @@ -91,6 +99,28 @@ export function generateBuiltInStepSnippet( }, }; break; + case 'workflow.output': { + const withBlock: Record = {}; + const normalized = normalizeFieldsToJsonSchema(workflowOutputs); + if (normalized?.properties && Object.keys(normalized.properties).length > 0) { + for (const [name, propSchema] of Object.entries(normalized.properties)) { + const type = + propSchema && typeof propSchema === 'object' && 'type' in propSchema + ? (propSchema as { type?: string }).type + : undefined; + const placeholder = type === 'string' ? '"${1:value}"' : '${1:value}'; + withBlock[name] = placeholder; + } + } else { + withBlock.output_name = '${1:value}'; + } + parameters = { with: withBlock }; + break; + } + case 'workflow.fail': + parameters = { + with: { message: '${1:Error message}' }, + }; case 'loop.break': case 'loop.continue': parameters = {}; diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_connector_snippet.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_connector_snippet.ts index aefc42633d3b4..41767e95eab79 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_connector_snippet.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_connector_snippet.ts @@ -134,7 +134,6 @@ export function connectorTypeRequiresConnectorId( dynamicConnectorTypes?: Record ): boolean { // Built-in step types don't need connector-id - // Use isBuiltInStepType to check against the actual schema definition if (isBuiltInStepType(connectorType)) { return false; } diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/validation/validate_workflow_fields.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/validation/validate_workflow_fields.ts new file mode 100644 index 0000000000000..7788201146cdf --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/validation/validate_workflow_fields.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { buildFieldsZodValidator } from '@kbn/workflows/spec/lib/build_fields_zod_validator'; +import { + type NormalizableFieldSchema, + normalizeFieldsToJsonSchema, +} from '@kbn/workflows/spec/lib/field_conversion'; +import { z } from '@kbn/zod/v4'; + +// Generic interface for validation errors +export interface WorkflowFieldValidationError { + fieldName: string; + message: string; +} + +/** + * Creates a Zod validator for workflow fields (inputs or outputs). + * Supports both legacy array format and JSON Schema format. + */ +export function makeWorkflowFieldsValidator(fields: NormalizableFieldSchema) { + const normalized = normalizeFieldsToJsonSchema(fields); + if (normalized?.properties && Object.keys(normalized.properties).length > 0) { + return buildFieldsZodValidator(normalized); + } + return z.object({}) as z.ZodObject>; +} + +/** + * Validates workflow fields (inputs or outputs) against the target schema + * Returns validation errors if any + * + * @param fieldKind - 'input' or 'output' for error messages + */ +export function validateWorkflowFields( + values: Record | undefined, + targetFields: NormalizableFieldSchema | undefined, + fieldKind: 'input' | 'output' +): { isValid: boolean; errors: WorkflowFieldValidationError[] } { + const fieldKindPlural = fieldKind === 'input' ? 'inputs' : 'outputs'; + + if (!targetFields) { + return { isValid: true, errors: [] }; + } + + const normalized = normalizeFieldsToJsonSchema(targetFields); + if (!normalized?.properties || Object.keys(normalized.properties).length === 0) { + return { isValid: true, errors: [] }; + } + + if (!values) { + const requiredFields = normalized.required || []; + if (requiredFields.length > 0) { + return { + isValid: false, + errors: [ + { + fieldName: '', + message: `Missing required ${fieldKindPlural}: ${requiredFields.join(', ')}`, + }, + ], + }; + } + return { isValid: true, errors: [] }; + } + + const validator = makeWorkflowFieldsValidator(targetFields); + const result = validator.safeParse(values); + + if (!result.success) { + const errors: WorkflowFieldValidationError[] = result.error.issues.map((issue) => { + const fieldName = (issue.path[0] as string) || ''; + let message = issue.message; + + // Remove "Invalid input: " prefix if present (Zod's default prefix) + message = message.replace(/^Invalid input:\s*/, ''); + + const fieldSchema = normalized.properties?.[fieldName]; + const isRequired = normalized.required?.includes(fieldName) ?? false; + const fieldType = + fieldSchema && typeof fieldSchema === 'object' && 'type' in fieldSchema + ? (fieldSchema as { type?: string }).type + : undefined; + + if (issue.code === 'invalid_type' && message.includes('received undefined') && isRequired) { + message = 'this field is required'; + } else if (issue.code === 'invalid_union') { + if (fieldType === 'array' && values) { + const receivedValue = values[fieldName]; + if (receivedValue !== undefined) { + const receivedType = + receivedValue === null + ? 'null' + : Array.isArray(receivedValue) + ? 'array' + : typeof receivedValue; + message = `expected array, received ${receivedType}`; + } else { + message = 'expected array'; + } + } + } + + return { fieldName, message }; + }); + return { isValid: false, errors }; + } + + return { isValid: true, errors: [] }; +} diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/validation/validate_workflow_outputs.test.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/validation/validate_workflow_outputs.test.ts new file mode 100644 index 0000000000000..7bd224620c31c --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/validation/validate_workflow_outputs.test.ts @@ -0,0 +1,410 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { WorkflowOutput } from '@kbn/workflows'; +import type { JsonModelSchemaType } from '@kbn/workflows/spec/schema/common/json_model_schema'; +import { validateWorkflowOutputs } from './validate_workflow_outputs'; + +describe('validateWorkflowOutputs', () => { + describe('JSON Schema format outputs', () => { + it('should detect missing required field and wrong type', () => { + const jsonSchemaOutputs: JsonModelSchemaType = { + required: ['b'], + properties: { + a: { type: 'string', default: 'aaaa' }, + b: { type: 'number' }, + }, + }; + + const result = validateWorkflowOutputs({ a: {} }, jsonSchemaOutputs); + expect(result.isValid).toBe(false); + expect(result.errors.length).toBeGreaterThanOrEqual(1); + // Should catch wrong type for 'a' (object instead of string) and/or missing required 'b' + const errorNames = result.errors.map((e) => e.outputName); + expect(errorNames).toContain('b'); + }); + + it('should detect wrong type for field with default', () => { + const jsonSchemaOutputs: JsonModelSchemaType = { + properties: { + a: { type: 'string', default: 'aaaa' }, + }, + }; + + const result = validateWorkflowOutputs({ a: {} }, jsonSchemaOutputs); + expect(result.isValid).toBe(false); + expect(result.errors[0].outputName).toBe('a'); + }); + + it('should detect missing required field when not provided', () => { + const jsonSchemaOutputs: JsonModelSchemaType = { + required: ['b'], + properties: { + a: { type: 'string' }, + b: { type: 'number' }, + }, + }; + + const result = validateWorkflowOutputs({ a: 'hello' }, jsonSchemaOutputs); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.outputName === 'b')).toBe(true); + }); + + it('should pass when all required fields are provided with correct types', () => { + const jsonSchemaOutputs: JsonModelSchemaType = { + required: ['b'], + properties: { + a: { type: 'string' }, + b: { type: 'number' }, + }, + }; + + const result = validateWorkflowOutputs({ a: 'hello', b: 42 }, jsonSchemaOutputs); + expect(result.isValid).toBe(true); + }); + }); + + describe('when target workflow has no outputs', () => { + it('should return valid for any outputs or no outputs', () => { + expect(validateWorkflowOutputs({}, undefined).isValid).toBe(true); + expect(validateWorkflowOutputs(undefined, undefined).isValid).toBe(true); + expect(validateWorkflowOutputs({ extra: 'value' }, []).isValid).toBe(true); + }); + }); + + describe('when outputs are missing', () => { + it('should return error for missing required output', () => { + const targetOutputs: WorkflowOutput[] = [ + { + name: 'result', + type: 'string', + required: true, + }, + ]; + + const result = validateWorkflowOutputs(undefined, targetOutputs); + expect(result.isValid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].outputName).toBe(''); + expect(result.errors[0].message).toContain('Missing required outputs'); + expect(result.errors[0].message).toContain('result'); + }); + + it('should return valid when no required outputs', () => { + const targetOutputs: WorkflowOutput[] = [ + { + name: 'result', + type: 'string', + required: false, + }, + ]; + + const result = validateWorkflowOutputs(undefined, targetOutputs); + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should return error for multiple missing required outputs', () => { + const targetOutputs: WorkflowOutput[] = [ + { + name: 'result', + type: 'string', + required: true, + }, + { + name: 'count', + type: 'number', + required: true, + }, + { + name: 'optional', + type: 'string', + required: false, + }, + ]; + + const result = validateWorkflowOutputs(undefined, targetOutputs); + expect(result.isValid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('result'); + expect(result.errors[0].message).toContain('count'); + }); + }); + + describe('string output validation', () => { + it('should validate string output and return error for wrong type', () => { + const targetOutputs: WorkflowOutput[] = [ + { + name: 'result', + type: 'string', + required: true, + }, + ]; + + expect(validateWorkflowOutputs({ result: 'test' }, targetOutputs).isValid).toBe(true); + const errorResult = validateWorkflowOutputs({ result: 123 }, targetOutputs); + expect(errorResult.isValid).toBe(false); + expect(errorResult.errors[0].outputName).toBe('result'); + expect(errorResult.errors[0].message).toContain('string'); + }); + + it('should allow optional string output to be missing', () => { + const targetOutputs: WorkflowOutput[] = [ + { + name: 'result', + type: 'string', + required: false, + }, + ]; + + expect(validateWorkflowOutputs({}, targetOutputs).isValid).toBe(true); + }); + }); + + describe('number output validation', () => { + it('should validate number output and return error for wrong type', () => { + const targetOutputs: WorkflowOutput[] = [ + { + name: 'count', + type: 'number', + required: true, + }, + ]; + + expect(validateWorkflowOutputs({ count: 42 }, targetOutputs).isValid).toBe(true); + const errorResult = validateWorkflowOutputs({ count: '42' }, targetOutputs); + expect(errorResult.isValid).toBe(false); + expect(errorResult.errors[0].outputName).toBe('count'); + expect(errorResult.errors[0].message).toContain('number'); + }); + }); + + describe('boolean output validation', () => { + it('should validate boolean output and return error for wrong type', () => { + const targetOutputs: WorkflowOutput[] = [ + { + name: 'success', + type: 'boolean', + required: true, + }, + ]; + + expect(validateWorkflowOutputs({ success: true }, targetOutputs).isValid).toBe(true); + const errorResult = validateWorkflowOutputs({ success: 'true' }, targetOutputs); + expect(errorResult.isValid).toBe(false); + expect(errorResult.errors[0].outputName).toBe('success'); + expect(errorResult.errors[0].message).toContain('boolean'); + }); + }); + + describe('choice output validation', () => { + it('should validate choice output correctly', () => { + const targetOutputs: WorkflowOutput[] = [ + { + name: 'status', + type: 'choice', + required: true, + options: ['success', 'failure', 'pending'], + }, + ]; + + expect(validateWorkflowOutputs({ status: 'success' }, targetOutputs).isValid).toBe(true); + expect(validateWorkflowOutputs({ status: 'failure' }, targetOutputs).isValid).toBe(true); + + const errorResult = validateWorkflowOutputs({ status: 'invalid' }, targetOutputs); + expect(errorResult.isValid).toBe(false); + expect(errorResult.errors[0].outputName).toBe('status'); + }); + + it('should allow optional choice output to be missing', () => { + const targetOutputs: WorkflowOutput[] = [ + { + name: 'status', + type: 'choice', + required: false, + options: ['success', 'failure'], + }, + ]; + + expect(validateWorkflowOutputs({}, targetOutputs).isValid).toBe(true); + }); + }); + + describe('array output validation', () => { + it('should validate array of strings', () => { + const targetOutputs: WorkflowOutput[] = [ + { + name: 'items', + type: 'array', + required: true, + }, + ]; + + expect(validateWorkflowOutputs({ items: ['a', 'b', 'c'] }, targetOutputs).isValid).toBe(true); + expect(validateWorkflowOutputs({ items: [1, 2, 3] }, targetOutputs).isValid).toBe(true); + expect(validateWorkflowOutputs({ items: [true, false] }, targetOutputs).isValid).toBe(true); + }); + + it('should return error for non-array value when array expected', () => { + const targetOutputs: WorkflowOutput[] = [ + { + name: 'items', + type: 'array', + required: true, + }, + ]; + + const errorResult = validateWorkflowOutputs({ items: 'not an array' }, targetOutputs); + expect(errorResult.isValid).toBe(false); + expect(errorResult.errors[0].outputName).toBe('items'); + expect(errorResult.errors[0].message).toContain('array'); + expect(errorResult.errors[0].message).toContain('string'); + }); + + it('should validate minItems constraint', () => { + const targetOutputs: WorkflowOutput[] = [ + { + name: 'items', + type: 'array', + required: true, + minItems: 2, + }, + ]; + + expect(validateWorkflowOutputs({ items: ['a', 'b'] }, targetOutputs).isValid).toBe(true); + expect(validateWorkflowOutputs({ items: ['a', 'b', 'c'] }, targetOutputs).isValid).toBe(true); + + const errorResult = validateWorkflowOutputs({ items: ['a'] }, targetOutputs); + expect(errorResult.isValid).toBe(false); + expect(errorResult.errors[0].outputName).toBe('items'); + }); + + it('should validate maxItems constraint', () => { + const targetOutputs: WorkflowOutput[] = [ + { + name: 'items', + type: 'array', + required: true, + maxItems: 2, + }, + ]; + + expect(validateWorkflowOutputs({ items: ['a'] }, targetOutputs).isValid).toBe(true); + expect(validateWorkflowOutputs({ items: ['a', 'b'] }, targetOutputs).isValid).toBe(true); + + const errorResult = validateWorkflowOutputs({ items: ['a', 'b', 'c'] }, targetOutputs); + expect(errorResult.isValid).toBe(false); + expect(errorResult.errors[0].outputName).toBe('items'); + }); + + it('should validate both minItems and maxItems constraints', () => { + const targetOutputs: WorkflowOutput[] = [ + { + name: 'items', + type: 'array', + required: true, + minItems: 2, + maxItems: 3, + }, + ]; + + expect(validateWorkflowOutputs({ items: ['a', 'b'] }, targetOutputs).isValid).toBe(true); + expect(validateWorkflowOutputs({ items: ['a', 'b', 'c'] }, targetOutputs).isValid).toBe(true); + + const tooFewResult = validateWorkflowOutputs({ items: ['a'] }, targetOutputs); + expect(tooFewResult.isValid).toBe(false); + + const tooManyResult = validateWorkflowOutputs({ items: ['a', 'b', 'c', 'd'] }, targetOutputs); + expect(tooManyResult.isValid).toBe(false); + }); + + it('should allow optional array output to be missing', () => { + const targetOutputs: WorkflowOutput[] = [ + { + name: 'items', + type: 'array', + required: false, + }, + ]; + + expect(validateWorkflowOutputs({}, targetOutputs).isValid).toBe(true); + }); + }); + + describe('multiple outputs validation', () => { + it('should validate all outputs correctly', () => { + const targetOutputs: WorkflowOutput[] = [ + { + name: 'result', + type: 'string', + required: true, + }, + { + name: 'count', + type: 'number', + required: true, + }, + { + name: 'success', + type: 'boolean', + required: false, + }, + ]; + + expect( + validateWorkflowOutputs( + { + result: 'done', + count: 42, + success: true, + }, + targetOutputs + ).isValid + ).toBe(true); + + expect( + validateWorkflowOutputs( + { + result: 'done', + count: 42, + }, + targetOutputs + ).isValid + ).toBe(true); + }); + + it('should return errors for all invalid outputs', () => { + const targetOutputs: WorkflowOutput[] = [ + { + name: 'result', + type: 'string', + required: true, + }, + { + name: 'count', + type: 'number', + required: true, + }, + ]; + + const errorResult = validateWorkflowOutputs( + { + result: 123, + count: 'not a number', + }, + targetOutputs + ); + + expect(errorResult.isValid).toBe(false); + expect(errorResult.errors).toHaveLength(2); + expect(errorResult.errors.some((e) => e.outputName === 'result')).toBe(true); + expect(errorResult.errors.some((e) => e.outputName === 'count')).toBe(true); + }); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/validation/validate_workflow_outputs.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/validation/validate_workflow_outputs.ts new file mode 100644 index 0000000000000..bdc8dedb67fe0 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/validation/validate_workflow_outputs.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { NormalizableFieldSchema } from '@kbn/workflows/spec/lib/field_conversion'; +import { validateWorkflowFields } from './validate_workflow_fields'; + +export interface WorkflowOutputValidationError { + outputName: string; + message: string; +} + +/** + * Validates workflow outputs against the workflow's output schema + * Returns validation errors if any + */ +export function validateWorkflowOutputs( + outputs: Record | undefined, + targetWorkflowOutputs: NormalizableFieldSchema | undefined +): { isValid: boolean; errors: WorkflowOutputValidationError[] } { + const result = validateWorkflowFields(outputs, targetWorkflowOutputs, 'output'); + + // Map generic field errors to output-specific errors + return { + isValid: result.isValid, + errors: result.errors.map((error) => ({ + outputName: error.fieldName, + message: error.message, + })), + }; +} diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/styles/use_dynamic_type_icons.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/styles/use_dynamic_type_icons.ts index ff5badac1918e..91b90e832a77c 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/styles/use_dynamic_type_icons.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/styles/use_dynamic_type_icons.ts @@ -84,6 +84,14 @@ export const predefinedStepTypes = [ actionTypeId: 'workflow.executeAsync', displayName: 'Workflow Execute Async', }, + { + actionTypeId: 'workflow.output', + displayName: 'Workflow Output', + }, + { + actionTypeId: 'workflow.fail', + displayName: 'Workflow Fail', + }, { actionTypeId: 'loop.break', displayName: 'Break', diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.test.tsx b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.test.tsx index e9fb904c81e4b..c6f00377bdb2f 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.test.tsx +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.test.tsx @@ -50,8 +50,7 @@ jest.mock('../../../features/validate_workflow_yaml/lib/use_yaml_validation', () useYamlValidation: () => ({ error: null, isLoading: false, - validateVariables: jest.fn(), - handleMarkersChanged: jest.fn(), + validationResults: [], }), })); diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx index c8c23f1dc6a12..f4a74021506b0 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx @@ -259,15 +259,34 @@ export const WorkflowYAMLEditor = ({ ]; }, [workflowJsonSchemaStrict, workflowSchemaUriStrict]); - const { error: errorValidating, isLoading: isLoadingValidation } = useYamlValidation( - editorRef.current - ); + const { + error: errorValidating, + isLoading: isLoadingValidation, + validationResults: customValidationResults, + } = useYamlValidation(editorRef.current); + + const { + validationErrors: interceptorValidationErrors, + transformMonacoMarkers, + handleMarkersChanged, + } = useMonacoMarkersChangedInterceptor({ + yamlDocumentRef, + workflowYamlSchema: workflowYamlSchema as z.ZodSchema, + }); - const { validationErrors, transformMonacoMarkers, handleMarkersChanged } = - useMonacoMarkersChangedInterceptor({ - yamlDocumentRef, - workflowYamlSchema: workflowYamlSchema as z.ZodSchema, - }); + const transformMonacoMarkersRef = useRef(transformMonacoMarkers); + transformMonacoMarkersRef.current = transformMonacoMarkers; + const handleMarkersChangedRef = useRef(handleMarkersChanged); + handleMarkersChangedRef.current = handleMarkersChanged; + + // Custom validations from the hook are the source of truth; add Monaco YAML schema errors from the interceptor + const validationErrors = useMemo( + () => [ + ...customValidationResults, + ...(interceptorValidationErrors?.filter((e) => e.owner === 'yaml') ?? []), + ], + [customValidationResults, interceptorValidationErrors] + ); // Sync validation error state to Redux so sibling components (e.g. header toggle) can react useEffect(() => { @@ -553,28 +572,25 @@ export const WorkflowYAMLEditor = ({ }, [isExecutionYaml]); useEffect(() => { - // Monkey patching - // 1. to set the initial markers https://github.com/suren-atoyan/monaco-react/issues/70#issuecomment-760389748 - // 2. to intercept and format markers validation messages – this prevents Monaco from ever seeing the problematic numeric enum messages + // Patch setModelMarkers to set initial markers (monaco-react#70) and to intercept/format + // validation messages. Effect has empty deps and uses refs for callbacks so it never + // re-runs; re-running would briefly restore the original and drop validation markers. const setModelMarkers = monaco.editor.setModelMarkers; monaco.editor.setModelMarkers = function (model, owner, markers) { - // as we intercepted the setModelMarkers method, we need to check if the call is from the current editor to avoid setting markers which could come from other editors const editorUri = editorRef.current?.getModel()?.uri; if (model.uri.path !== editorUri?.path) { - // skip setting markers for other editors setModelMarkers.call(monaco.editor, model, owner, markers); return; } - const transformedMarkers = transformMonacoMarkers(model, owner, markers); + const transformedMarkers = transformMonacoMarkersRef.current(model, owner, markers); setModelMarkers.call(monaco.editor, model, owner, transformedMarkers); - handleMarkersChanged(model, owner, transformedMarkers); + handleMarkersChangedRef.current(model, owner, transformedMarkers); }; return () => { - // Reset the monaco.editor.setModelMarkers to the original function monaco.editor.setModelMarkers = setModelMarkers; }; - }, [handleMarkersChanged, transformMonacoMarkers]); + }, []); // Debug const downloadSchema = useCallback(() => {